feat(server): flat top-level party peers + pure-WORM archive (impl)

Reshape the project layout from "archive/ is the only physical dir + six
virtual aggregators" to a flat set of physical, party-partitioned peers:

  archive/<party>/{received,issued}   pure WORM (one rule, no exceptions)
  incoming|reviewing|working|staging/<party>/   workspaces
  mdl|rsk/<party>/*.yaml              registers (cross-party aggregate at the
                                      peer root, $party from the real subdir)
  ssr/<party>.yaml                    submittal status register AND the
                                      authoritative party registry

A party exists iff ssr/<party>.yaml exists; the new `party_source: ssr`
cascade key gates party-folder creation under every other peer (archive
included) — create <peer>/<party> only when the registry row exists, else
409. Registration is a plain create of ssr/<party>.yaml (no WORM gymnastics),
so archive/ stays purely WORM.

Server core:
- defaults.zddc.yaml rewritten to the flat-peer + WORM-archive + party_source
  shape; every virtual: removed; mdl/rsk get document_controller rwcd.
- slots.go: projectPeers/IsProjectPeer; perPartySlots={received,issued}.
- party_source key end-to-end (file.go/walker/lookups/cascade) + PartyRegistered.
- ensure.go canonical-ancestors generalized to peers; virtual reject removed.
- virtualviews.go: deleted the virtual-URL resolver/types/regex; kept
  ListParties (reads ssr/*) + repointed ListRollupRows (physical <peer>/*/*).
- fs/tree.go: mdl/rsk peer-root listing aggregates physical party subdirs
  (replaces the subdir folder-nav); ssr flat; spec entries advertised.
- fileapi.go: deleted virtual PUT/DELETE rewrites; mkdir allowlist → peers;
  partySourceGate on mkdir/PUT/move.
- virtualviewhandler.go → ServeInjectedRow ($party/name injected on read so
  the tables client renders the column unchanged).
- ssr/form/table handlers repointed to real paths (SSR create writes
  ssr/<party>.yaml; rollup create writes mdl|rsk/<party>/<file>.yaml; SSR
  rename is registry-only); IsDefaultSpec recognizes the new spec locations.
- accept-transmittal source incoming/<party>/<txn> (+ PartyRegistered guard);
  plan-review scaffolds top-level reviewing/<party> + staging/<party>.
- main.go dispatch: removed virtual-row GET + folder-nav 302; injects the
  source column on register-row reads.

Non-test build is green. Test suites still assert the OLD layout (verified:
all current failures are stale expectations, not bugs) — the test rewrite,
browse/tables client updates, and the data-migration script follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-03 11:40:09 -05:00
parent 8875d490f5
commit db110665f0
18 changed files with 882 additions and 1900 deletions

View file

@ -1052,9 +1052,8 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// Default-spec fallback for the embedded table.yaml / form.yaml // Default-spec fallback for the embedded table.yaml / form.yaml
// files served when no operator file exists on disk: // files served when no operator file exists on disk:
// //
// <project>/archive/<party>/{mdl,rsk}/{table,form}.yaml // <project>/{ssr,mdl,rsk}/{table,form}.yaml (aggregate/registry)
// <project>/archive/<party>/ssr.form.yaml // <project>/{mdl,rsk}/<party>/{table,form}.yaml (per-party)
// <project>/{ssr,mdl,rsk}/{table,form}.yaml
// //
// The table app fetches these client-side; the fallback lets // The table app fetches these client-side; the fallback lets
// a fresh project work out of the box. ACL gates against the // a fresh project work out of the box. ACL gates against the
@ -1078,47 +1077,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return return
} }
} }
// Virtual project-level table views (SSR / MDL rollup / RSK // (Register rows are real files now — ssr/<party>.yaml and
// rollup). The virtual row URL doesn't exist on disk; the // mdl|rsk/<party>/<file>.yaml — so a GET of one hits the
// underlying canonical file lives in <project>/archive/<party>/. // on-disk serve path below, where $party/name is injected;
// ACL evaluates against the canonical party-archive path so // it never lands in this not-found branch. working/staging/
// non-owners see the row read-only and party owners can edit. // reviewing are real directories navigated normally. The old
if r.Method == http.MethodGet || r.Method == http.MethodHead { // virtual-row serve + folder-nav 302 are gone.)
if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind.IsRowKind() {
chain, _ := zddc.EffectivePolicy(cfg.Root, vv.PartyArchive)
if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeVirtualViewRow(w, r, vv)
return
}
}
// Virtual folder-nav redirect. URLs of the shape
// /<project>/{working,staging,reviewing}/<party>[/...]
// 302 to /<project>/archive/<party>/<slot>[/...] — the
// canonical physical path. The per-party folder-nav
// virtual itself has no on-disk presence; the redirect
// hands the client off to the real address so subsequent
// navigation, sharing, and bookmarks stay canonical.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind == zddc.VirtualViewFolderNavRedir {
target := vv.CanonicalURL
// Preserve trailing slash from the request, since
// the canonical URL is a directory.
if strings.HasSuffix(urlPath, "/") && !strings.HasSuffix(target, "/") {
target += "/"
}
// Preserve query string verbatim — clients
// passing ?hidden=1 etc. should land at the same
// query on the canonical URL.
if q := r.URL.RawQuery; q != "" {
target += "?" + q
}
http.Redirect(w, r, target, http.StatusFound)
return
}
}
// File doesn't exist at this path. Before falling through to // File doesn't exist at this path. Before falling through to
// app-HTML routing or 404, check the two virtual-file-extension // app-HTML routing or 404, check the two virtual-file-extension
// shapes that ZDDC exposes through the listing convention: // shapes that ZDDC exposes through the listing convention:
@ -1347,9 +1312,40 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return return
} }
// Register rows are real files; inject the path-derived source column
// ($party for mdl/rsk rows, name for ssr rows) on read so the tables
// tool renders it as a read-only column. The client strips it on save.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if field, value, ok := registerRowField(urlPath); ok {
handler.ServeInjectedRow(w, r, absPath, field, value)
return
}
}
handler.ServeFile(w, r, absPath) handler.ServeFile(w, r, absPath)
} }
// registerRowField returns the path-derived column to inject when urlPath
// names an aggregate register row: ($party, <party>) for
// /<project>/{mdl,rsk}/<party>/<file>.yaml, or (name, <party>) for
// /<project>/ssr/<party>.yaml. ok=false otherwise (incl. spec files).
func registerRowField(urlPath string) (field, value string, ok bool) {
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
switch len(parts) {
case 3:
if parts[1] == "ssr" && strings.HasSuffix(parts[2], ".yaml") &&
parts[2] != "table.yaml" && parts[2] != "form.yaml" {
return "name", strings.TrimSuffix(parts[2], ".yaml"), true
}
case 4:
if (parts[1] == "mdl" || parts[1] == "rsk") && strings.HasSuffix(parts[3], ".yaml") &&
parts[3] != "table.yaml" && parts[3] != "form.yaml" {
return "$party", parts[2], true
}
}
return "", "", false
}
// runPeriodicRescan calls idx.Rebuild on `interval` until ctx is cancelled. // runPeriodicRescan calls idx.Rebuild on `interval` until ctx is cancelled.
// Each tick walks fsRoot from scratch and atomically replaces the live index; // Each tick walks fsRoot from scratch and atomically replaces the live index;
// concurrent reads are safe via the index's RWMutex. Errors are logged but do // concurrent reads are safe via the index's RWMutex. Errors are logged but do

View file

@ -197,112 +197,67 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
result = append(result, fi) result = append(result, fi)
} }
// Per-user virtual home: when listing // At a project root, surface the cascade-declared top-level peers
// <project>/archive/<party>/working/ for an authenticated viewer, // (archive + the party-partitioned workspaces/registers) as virtual
// surface a synthetic <viewer-email>/ entry if no real folder of
// any case variant already exists for them. A first write to that
// path materialises a real folder with auto-own .zddc; subsequent
// listings drop the synthetic entry naturally.
if syn, ok := virtualUserHomeEntry(ctx, decider, fsRoot, dirPath, principal, baseURL, result); ok {
result = append(result, syn)
}
// At a project root, surface the cascade-declared top-level
// folders (archive plus the six virtual aggregators) as virtual
// entries when no on-disk variant exists. The browse client // entries when no on-disk variant exists. The browse client
// previously did this client-side; moving it server-side lets the // previously did this client-side; moving it server-side lets the
// directory's `display:` map apply to virtual entries the same // directory's `display:` map apply to virtual entries the same way
// way it applies to real ones. // it applies to real ones.
result = append(result, virtualCanonicalFolders(ctx, decider, fsRoot, absDir, principal, baseURL, result, displayMap)...) result = append(result, virtualCanonicalFolders(ctx, decider, fsRoot, absDir, principal, baseURL, result, displayMap)...)
// Project-level virtual views: // Tables register peers (ssr/mdl/rsk) at the project root:
// //
// Row rollups (ssr/mdl/rsk) — synthesize row entries (Writable // mdl/ and rsk/ render an AGGREGATE table — one row per physical
// bit per the canonical archive/<party>/ chain) plus synthetic // <project>/<peer>/<party>/*.yaml across all parties. This REPLACES
// table.yaml/form.yaml entries so the tables tool's client-side // the physical party-subdir listing built above so the peer root
// walkServer finds the spec without a 404 round-trip. Spec bytes // shows the combined table (not a folder-nav of parties). Each
// come from main.go IsDefaultSpec fallback; row reads go through // row's URL is its real path; ACL is evaluated against the row's
// handler.ServeVirtualViewRow which path-injects name/$party. // own chain; the $party column is injected on read (see the rollup
// row serve). <peer>/<party>/ lists its rows normally.
// //
// Folder-nav (working/staging/reviewing) — synthesize one // ssr/ keeps its flat real-file listing (one ssr/<party>.yaml per
// IsDir=true entry per party whose archive/<party>/<slot>/ has // party = one row); only the spec entries are added.
// non-empty content (in-flight filter). The browse client //
// follows a click through to the virtual URL // All three advertise synthetic table.yaml/form.yaml entries so the
// <project>/<slot>/<party>/ which the dispatcher 302s to the // tables tool's client-side walkServer finds the spec without a 404
// canonical archive/<party>/<slot>/. // (spec bytes come from main.go's IsDefaultSpec fallback).
if vv := zddc.ResolveVirtualView(fsRoot, strings.TrimSuffix(baseURL, "/")); vv.Resolved && vv.Kind.IsRootKind() { if segsURL := strings.Split(strings.Trim(baseURL, "/"), "/"); len(segsURL) == 2 && zddc.IsRowSlot(segsURL[1]) {
partyChains := make(map[string]zddc.PolicyChain) peer := segsURL[1]
chainFor := func(partyAbs string) zddc.PolicyChain { projectAbs := filepath.Join(fsRoot, segsURL[0])
if c, ok := partyChains[partyAbs]; ok { if peer == "mdl" || peer == "rsk" {
return c rows, _ := zddc.ListRollupRows(projectAbs, peer)
agg := make([]listing.FileInfo, 0, len(rows)+2)
rowChains := make(map[string]zddc.PolicyChain)
for _, row := range rows {
rowDir := filepath.Dir(row.Abs)
chain, ok := rowChains[rowDir]
if !ok {
chain, _ = zddc.EffectivePolicy(fsRoot, rowDir)
rowChains[rowDir] = chain
} }
c, _ := zddc.EffectivePolicy(fsRoot, partyAbs) verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, row.RelURL)
partyChains[partyAbs] = c
return c
}
appendVirtualRow := func(syntheticName, partyAbs string) {
rowURL := baseURL + url.PathEscape(syntheticName)
chain := chainFor(partyAbs)
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, rowURL)
if !verbs.Has(zddc.VerbR) { if !verbs.Has(zddc.VerbR) {
return continue
} }
result = append(result, listing.FileInfo{ agg = append(agg, listing.FileInfo{
Name: syntheticName, Name: row.Filename,
URL: rowURL, URL: row.RelURL,
IsDir: false, IsDir: false,
Virtual: true,
Writable: verbs.Has(zddc.VerbW), Writable: verbs.Has(zddc.VerbW),
Verbs: verbs.String(), Verbs: verbs.String(),
}) })
} }
appendVirtualPartyDir := func(party, partyAbs string) { result = agg
dirURL := baseURL + url.PathEscape(party) + "/"
chain := chainFor(partyAbs)
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, dirURL)
if !verbs.Has(zddc.VerbR) {
return
} }
result = append(result, listing.FileInfo{ // Advertise the tables specs (skip any already present on disk).
Name: party + "/", have := make(map[string]bool, len(result))
URL: dirURL, for _, fi := range result {
IsDir: true, have[fi.Name] = true
Virtual: true,
Verbs: verbs.String(),
})
} }
switch vv.Slot {
case "ssr":
parties, _ := zddc.ListSSRParties(fsRoot, vv.ProjectAbs)
for _, party := range parties {
partyAbs := filepath.Join(vv.ProjectAbs, "archive", party)
appendVirtualRow(party+".yaml", partyAbs)
}
case "mdl", "rsk":
rows, _ := zddc.ListRollupRows(fsRoot, vv.ProjectAbs, vv.Slot)
for _, row := range rows {
partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party)
appendVirtualRow(row.SyntheticName, partyAbs)
}
case "working", "staging", "reviewing":
parties, _ := zddc.ListPartyDirsInSlot(fsRoot, vv.ProjectAbs, vv.Slot)
for _, party := range parties {
partyAbs := filepath.Join(vv.ProjectAbs, "archive", party)
appendVirtualPartyDir(party, partyAbs)
}
}
// Row rollups carry synthetic spec entries so the tables tool
// can walkServer them. Folder-nav virtuals don't need spec
// files — they're just party listings rendered by browse.
// Verbs reflect actual cascade authority at each synthetic
// spec's URL so elevated admins see them as writable (they
// CAN materialise an override .zddc / spec by PUTting to
// the virtual path). Non-admins fall through to the default
// 'r' that the embedded baseline grants on the rollup view.
if zddc.IsRowSlot(vv.Slot) {
for _, spec := range []string{"table.yaml", "form.yaml"} { for _, spec := range []string{"table.yaml", "form.yaml"} {
if have[spec] {
continue
}
specURL := baseURL + spec specURL := baseURL + spec
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, specURL) verbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, specURL)
if !verbs.Has(zddc.VerbR) { if !verbs.Has(zddc.VerbR) {
@ -317,7 +272,6 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
}) })
} }
} }
}
// Workflow folder: append a virtual `received/` entry whose backing // Workflow folder: append a virtual `received/` entry whose backing
// is .zddc.received_path. The entry's URL stays under the workflow // is .zddc.received_path. The entry's URL stays under the workflow
@ -458,67 +412,6 @@ func virtualCanonicalFolders(ctx context.Context, decider policy.Decider, fsRoot
return synth return synth
} }
// virtualUserHomeEntry returns the synthetic <viewer-email>/ entry that
// should be appended to a per-party working/ listing, or (zero, false)
// when no synthetic entry applies.
//
// Under the canonical layout, per-user homes live at
// <project>/archive/<party>/working/<email>/ (depth-4 working slot
// inside the party folder). The synthetic entry fires when dirPath
// case-folds to <project>/archive/<party>/working and the viewer has
// no real home folder yet.
//
// Conditions for the entry to fire:
// - dirPath case-folds to <project>/archive/<party>/working at
// depth-4 of fsRoot
// - viewerEmail is non-empty
// - real does not already contain a directory entry that case-folds
// to viewerEmail (so a materialised home doesn't get duplicated)
func virtualUserHomeEntry(ctx context.Context, decider policy.Decider, fsRoot, dirPath string, principal zddc.Principal, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) {
if principal.Email == "" {
return listing.FileInfo{}, false
}
rel := strings.Trim(filepath.ToSlash(dirPath), "/")
parts := strings.Split(rel, "/")
if len(parts) != 4 ||
!strings.EqualFold(parts[1], "archive") ||
!strings.EqualFold(parts[3], "working") {
return listing.FileInfo{}, false
}
for _, fi := range real {
if !fi.IsDir {
continue
}
// fi.Name carries a trailing slash for dirs.
bare := strings.TrimSuffix(fi.Name, "/")
if strings.EqualFold(bare, principal.Email) {
return listing.FileInfo{}, false
}
}
// Compute verbs against the would-be home's own chain — the
// auto_own_fenced declaration in defaults.zddc.yaml means a real
// home grants the creator rwcda; the synthetic entry reports the
// same so client-side gating renders the "+ New" affordances
// immediately, before the first write materialises the folder.
homeAbs := filepath.Join(fsRoot, filepath.FromSlash(dirPath), principal.Email)
chain, err := zddc.EffectivePolicy(fsRoot, homeAbs)
if err != nil {
return listing.FileInfo{}, false
}
homeURL := baseURL + url.PathEscape(principal.Email) + "/"
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, homeURL)
if !verbs.Has(zddc.VerbR) {
return listing.FileInfo{}, false
}
return listing.FileInfo{
Name: principal.Email + "/",
URL: homeURL,
IsDir: true,
Virtual: true,
Verbs: verbs.String(),
}, true
}
// readDisplayMap parses dirAbs/.zddc and returns its Display map (or // readDisplayMap parses dirAbs/.zddc and returns its Display map (or
// nil when the file doesn't exist or has no display block). All keys // nil when the file doesn't exist or has no display block). All keys
// are case-folded to lowercase so lookupDisplay's case-insensitive // are case-folded to lowercase so lookupDisplay's case-insensitive

View file

@ -59,8 +59,8 @@ import (
const opAcceptTransmittal = "accept-transmittal" const opAcceptTransmittal = "accept-transmittal"
// incomingURLPattern matches /<project>/archive/<party>/incoming/<transmittal>/. // incomingURLPattern matches /<project>/incoming/<party>/<transmittal>/.
var incomingURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/incoming/([^/]+)/?$`) var incomingURLPattern = regexp.MustCompile(`^/([^/]+)/incoming/([^/]+)/([^/]+)/?$`)
type acceptRequest struct { type acceptRequest struct {
ReceivedDate string `yaml:"received_date"` ReceivedDate string `yaml:"received_date"`
@ -84,11 +84,17 @@ func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Re
cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/" cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/"
m := incomingURLPattern.FindStringSubmatch(cleanURL) m := incomingURLPattern.FindStringSubmatch(cleanURL)
if m == nil { if m == nil {
http.Error(w, "Bad Request — accept-transmittal must POST to /<project>/archive/<party>/incoming/<transmittal>/", http.StatusBadRequest) http.Error(w, "Bad Request — accept-transmittal must POST to /<project>/incoming/<party>/<transmittal>/", http.StatusBadRequest)
return return
} }
project, party, transmittalFolder := m[1], m[2], m[3] project, party, transmittalFolder := m[1], m[2], m[3]
// Filing requires the party to be registered (ssr/<party>.yaml).
if !zddc.PartyRegistered(filepath.Join(cfg.Root, project), "ssr", party) {
http.Error(w, "Conflict — unknown party \""+party+"\"; register it in ssr/ first", http.StatusConflict)
return
}
date, tracking, _, _, ok := zddc.ParseTransmittalFolder(transmittalFolder) date, tracking, _, _, ok := zddc.ParseTransmittalFolder(transmittalFolder)
if !ok { if !ok {
http.Error(w, "Bad Request — folder name does not conform to ZDDC transmittal grammar (expected YYYY-MM-DD_<tracking> (<status>) - <title>)", http.StatusBadRequest) http.Error(w, "Bad Request — folder name does not conform to ZDDC transmittal grammar (expected YYYY-MM-DD_<tracking> (<status>) - <title>)", http.StatusBadRequest)
@ -115,7 +121,7 @@ func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Re
} }
} }
incomingAbs := filepath.Join(cfg.Root, project, "archive", party, "incoming", transmittalFolder) incomingAbs := filepath.Join(cfg.Root, project, "incoming", party, transmittalFolder)
receivedAbs := filepath.Join(cfg.Root, project, "archive", party, "received", tracking) receivedAbs := filepath.Join(cfg.Root, project, "archive", party, "received", tracking)
receivedURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/" receivedURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"

View file

@ -299,23 +299,20 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
http.Error(w, "PUT must target a file, not a directory", http.StatusBadRequest) http.Error(w, "PUT must target a file, not a directory", http.StatusBadRequest)
return return
} }
// A PUT that would introduce a new party folder under a party_source
// Virtual project-level table views — SSR / MDL rollup / RSK // peer (e.g. working/<newparty>/file, or filing into
// rollup. The PUT URL lives in <project>/{ssr,mdl,rsk}/...; the // archive/<newparty>/received/) requires the party to be registered.
// underlying bytes belong inside <project>/archive/<party>/. We if rejected, why, _ := partySourceGate(cfg.Root, abs); rejected {
// rewrite abs + cleanURL to the canonical path so the rest of http.Error(w, why, http.StatusConflict)
// this function (ACL gate, ETag, audit, conversion-cache purge) return
// operates on the real file location.
//
// SSR row PUTs land at archive/<party>/ssr.yaml; MDL/RSK rollup
// row PUTs land at archive/<party>/<slot>/<file>.yaml. Same
// shape as the virtual-received rewrite below.
if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() {
abs = vv.CanonicalAbs
cleanURL = vv.CanonicalURL
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
} }
// Register rows (ssr/<party>.yaml, mdl|rsk/<party>/<file>.yaml) are
// real files in the flat-peer layout — a PUT targets them directly,
// no virtual→canonical rewrite. The path-derived $party/name column
// is injected only on read (ServeInjectedRow) and stripped by the
// client before submit.
// Virtual received/ rewrite. When the PUT targets a file under the // Virtual received/ rewrite. When the PUT targets a file under the
// synthetic <workflow>/received/<file> URL, the canonical record is // synthetic <workflow>/received/<file> URL, the canonical record is
// WORM — we can't write there. Convention: treat the drop as a // WORM — we can't write there. Convention: treat the drop as a
@ -510,20 +507,10 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
return return
} }
// Virtual project-level table views. SSR row deletes are refused // Register rows are real files — a DELETE targets them directly with
// (would orphan the party folder and its mdl/rsk contents) — use // the normal ACL gate. (Deleting an ssr/<party>.yaml de-registers the
// the archive view to delete a party. MDL/RSK rollup row deletes // party; the ssr/ ACL grants delete only to admins by default so a
// pass through to the canonical archive/<party>/<slot>/<file>.yaml // party with archived records can't be orphaned.)
// path with the normal ACL gate.
if vv := zddc.ResolveVirtualView(cfg.Root, cleanURL); vv.Resolved && vv.Kind.IsRowKind() {
if vv.Kind == zddc.VirtualViewSSRRow {
http.Error(w, "Method Not Allowed — delete the party folder via the archive view, not the SSR table", http.StatusMethodNotAllowed)
return
}
abs = vv.CanonicalAbs
cleanURL = vv.CanonicalURL
w.Header().Set("X-ZDDC-Resolved-Path", cleanURL)
}
info, err := os.Stat(abs) info, err := os.Stat(abs)
if err != nil { if err != nil {
@ -612,6 +599,12 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
http.Error(w, "MOVE destination must be a file path", http.StatusBadRequest) http.Error(w, "MOVE destination must be a file path", http.StatusBadRequest)
return return
} }
// A move whose destination introduces a new party folder under a
// party_source peer requires the party to be registered.
if rejected, why, _ := partySourceGate(cfg.Root, dstAbs); rejected {
http.Error(w, "destination: "+why, http.StatusConflict)
return
}
// Resolve canonical-folder casing on src + dst (no side effects). // Resolve canonical-folder casing on src + dst (no side effects).
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, srcAbs); err == nil { if r2, err := zddc.ResolveCanonicalPath(cfg.Root, srcAbs); err == nil {
@ -725,19 +718,14 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// Project-root mkdir policy: the only physical child allowed // Project-root mkdir policy: the only physical child allowed
// directly under <project>/ is `archive` (plus _/.-prefixed // directly under <project>/ is `archive` (plus _/.-prefixed
// system names). Mkdir of any other name — including the six // system names). Mkdir of any other name — including the six
// virtual aggregator names (ssr/mdl/rsk/working/staging/reviewing) // non-peer name — is rejected with 409.
// — is rejected with 409, because the virtual would shadow any
// physical folder created at the same URL.
if rejected, why := rejectProjectRootMkdir(cfg.Root, abs); rejected { if rejected, why := rejectProjectRootMkdir(cfg.Root, abs); rejected {
http.Error(w, why, http.StatusConflict) http.Error(w, why, http.StatusConflict)
return return
} }
// Mkdir INSIDE a virtual aggregator (working/staging/reviewing/…) has // A new party folder under a party_source peer requires the party to
// no physical home — the content is party-scoped. 409 with a pointer // be registered (ssr/<party>.yaml exists); else 409.
// at archive/<party>/<slot>/ rather than silently creating an if rejected, why := rejectUnregisteredPartyMkdir(cfg.Root, abs); rejected {
// unreachable shadow folder. (Browse's picker targets the archive/
// path directly, so this is the bypass fallback.)
if rejected, why := rejectProjectAggregatorMkdir(cfg.Root, abs); rejected {
http.Error(w, why, http.StatusConflict) http.Error(w, why, http.StatusConflict)
return return
} }
@ -852,48 +840,54 @@ func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
return false, "" return false, ""
} }
name := parts[1] name := parts[1]
if name == "archive" {
return false, ""
}
if strings.HasPrefix(name, "_") || strings.HasPrefix(name, ".") { if strings.HasPrefix(name, "_") || strings.HasPrefix(name, ".") {
// System-reserved namespace; allowed. // System-reserved namespace; allowed.
return false, "" return false, ""
} }
lower := strings.ToLower(name) if zddc.IsProjectPeer(name) {
if zddc.IsVirtualAggregatorSlot(lower) { return false, ""
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 the canonical peers (archive, incoming, working, staging, reviewing, mdl, rsk, ssr) and system-reserved (_/. prefix) folders may be created directly under a project."
} }
// rejectProjectAggregatorMkdir reports whether a mkdir lands INSIDE a // rejectUnregisteredPartyMkdir enforces the party_source cascade key: a
// project-level virtual aggregator — <project>/{ssr,mdl,rsk,working, // new <party> folder under a peer that declares party_source (every peer
// staging,reviewing}/<name>[/...] (depth 3+). Those slots aggregate // except ssr/) may be created only if the party is registered — i.e. the
// per-party content; a folder created there has no physical home. The // registry entry exists (ssr/<party>.yaml). Applies at the party-segment
// real location is archive/<party>/<slot>/<name>, so we 409 with a // depth and below (<project>/<peer>/<party>[/...]). Registration itself
// message pointing at the party-scoped path. (rejectProjectRootMkdir // (creating ssr/<party>.yaml) is not gated — ssr/ sets no party_source.
// handles the depth-2 case — the aggregator name itself.) The browse func rejectUnregisteredPartyMkdir(fsRoot, abs string) (bool, string) {
// "New folder" picker resolves the party client-side and targets the reject, msg, _ := partySourceGate(fsRoot, abs)
// archive/ path directly, so a user never trips this in normal use; it return reject, msg
// is the clean fallback for a direct/scripted mkdir that bypassed it. }
func rejectProjectAggregatorMkdir(fsRoot, abs string) (bool, string) {
// partySourceGate is the shared party_source check used by mkdir, PUT
// (create), and move (dst). It returns reject=true (+ a 409 message)
// when abs would introduce a <party> segment under a party_source peer
// for a party that isn't registered. The third return is the resolved
// party name (for callers that want to log it).
func partySourceGate(fsRoot, abs string) (reject bool, msg, party string) {
rel, err := filepath.Rel(fsRoot, abs) rel, err := filepath.Rel(fsRoot, abs)
if err != nil { if err != nil {
return false, "" return false, "", ""
} }
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
if rel == "." || strings.HasPrefix(rel, "../") { if rel == "." || strings.HasPrefix(rel, "../") {
return false, "" return false, "", ""
} }
parts := strings.Split(rel, "/") parts := strings.Split(rel, "/")
if len(parts) < 3 { if len(parts) < 3 {
return false, "" // depth-2 (the slot itself) is rejectProjectRootMkdir's job return false, "", "" // <project>/<peer> — no party segment yet
} }
if slot := strings.ToLower(parts[1]); zddc.IsVirtualAggregatorSlot(slot) { project, peer, p := parts[0], parts[1], parts[2]
return true, "Conflict — " + slot + "/ is a project-level virtual aggregator; folders here belong to a party. " + source := zddc.PartySourceAt(fsRoot, filepath.Join(fsRoot, project, peer))
"Create it under archive/<party>/" + slot + "/ — browse's \"New folder\" picker prompts you for the party." if source == "" {
return false, "", "" // peer does no party gating (e.g. ssr/)
} }
return false, "" if zddc.PartyRegistered(filepath.Join(fsRoot, project), source, p) {
return false, "", p
}
return true, "Conflict — unknown party \"" + p + "\". Register it first by creating " + source + "/" + p + ".yaml (the SSR form).", p
} }
// auditFile emits a structured log line for each file API operation. // auditFile emits a structured log line for each file API operation.

View file

@ -203,36 +203,11 @@ func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
if strings.HasSuffix(underlying, ".yaml") { if strings.HasSuffix(underlying, ".yaml") {
// /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the // /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the
// SAME directory as the row file (<dir>/form.yaml) UNLESS the // SAME directory as the row file (<dir>/form.yaml). Register rows
// URL maps to one of the project-level virtual views, in which // are real files now (ssr/<party>.yaml, mdl|rsk/<party>/<file>.yaml),
// case the canonical SpecPath / DataPath are inside the per- // so the in-dir rule resolves them directly — the spec falls back
// party archive folder. ResolveVirtualView handles the rewrite; // to the embedded default via IsDefaultSpecAbs when no on-disk
// SubmitURL stays as the virtual URL so the form POSTs back to // form.yaml exists.
// the same endpoint (which re-resolves to the same canonical
// paths on the second pass).
if vv := zddc.ResolveVirtualView(fsRoot, underlying); vv.Resolved && vv.Kind.IsRowKind() {
var specPath string
switch vv.Kind {
case zddc.VirtualViewSSRRow:
specPath = vv.SchemaAbs
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
specPath = filepath.Join(vv.PartyArchive, vv.Slot, "form.yaml")
}
if !specEligible(specPath) {
return nil
}
kind := "render-edit"
if method == http.MethodPost {
kind = "update"
}
return &FormRequest{
Kind: kind,
SpecPath: specPath,
DataPath: vv.CanonicalAbs,
SubmitURL: urlPath,
}
}
dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/"))) dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
dataAbs := filepath.Join(fsRoot, dataRel) dataAbs := filepath.Join(fsRoot, dataRel)
if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot { if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {

View file

@ -143,12 +143,12 @@ func executePlanReview(cfg config.Config, r *http.Request, project, party, track
receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel)) receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel))
cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/" cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"
// Hardcoded path convention. Every project has exactly one // Hardcoded path convention. The composite endpoint scaffolds a
// reviewing/ and one staging/ slot per party at fixed offsets; // submittal folder inside the top-level reviewing/<party>/ and
// the composite endpoint scaffolds inside the originating party's // staging/<party>/ peers; each carries received_path back to the
// slots. // canonical record in archive/<party>/received/<tracking>.
reviewingRoot := filepath.Join(cfg.Root, project, "archive", party, "reviewing") reviewingRoot := filepath.Join(cfg.Root, project, "reviewing", party)
stagingRoot := filepath.Join(cfg.Root, project, "archive", party, "staging") stagingRoot := filepath.Join(cfg.Root, project, "staging", party)
// Pre-flight authorisation. No ACL exception — we use existing // Pre-flight authorisation. No ACL exception — we use existing
// cascade grants: // cascade grants:

View file

@ -98,67 +98,37 @@ func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWrit
return return
} }
partyAbs := filepath.Join(cfg.Root, req.Project, "archive", name) rowURL := "/" + req.Project + "/ssr/" + name + ".yaml"
if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) { yamlAbs := filepath.Join(cfg.Root, req.Project, "ssr", name+".yaml")
if !strings.HasPrefix(yamlAbs, cfg.Root+string(filepath.Separator)) {
http.Error(w, "Not Found", http.StatusNotFound) http.Error(w, "Not Found", http.StatusNotFound)
return return
} }
partyURL := "/" + req.Project + "/archive/" + name + "/"
rowURL := "/" + req.Project + "/ssr/" + name + ".yaml"
// ACL gate: create at <project>/archive/<name>/. authorizeAction walks // ACL gate: create the registry row at <project>/ssr/<name>.yaml.
// up to the closest existing ancestor for the chain — typically // authorizeAction walks to the closest existing ancestor (ssr/ or the
// <project>/archive/, where document_controller carries rwc per the // project), where document_controller carries rwc per the cascade.
// project-level cascade. // Creating this file IS registering the party.
if !authorizeAction(cfg, w, r, partyAbs, partyURL, policy.ActionCreate) { if !authorizeAction(cfg, w, r, yamlAbs, rowURL, policy.ActionCreate) {
return return
} }
// Refuse to clobber an existing party folder. The SSR view shows // Refuse to clobber an existing registration — edit that row instead.
// any folder under archive/*/; if one with this name exists, the if _, err := os.Stat(yamlAbs); err == nil {
// user should edit that row instead of creating a duplicate. http.Error(w, "Conflict — party \""+name+"\" is already registered", http.StatusConflict)
if info, err := os.Stat(partyAbs); err == nil {
if info.IsDir() {
http.Error(w, "Conflict — a party folder with that name already exists", http.StatusConflict)
return
}
http.Error(w, "Conflict — a file exists at this path", http.StatusConflict)
return return
} else if !errors.Is(err, os.ErrNotExist) { } else if !errors.Is(err, os.ErrNotExist) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
// Materialize canonical ancestors (<project>/archive/) with auto-own // Materialize the ssr/ ancestor before writing the registry row.
// seeding before creating the party folder itself. Mirrors what the
// generic file-API mkdir does at fileapi.go:629-634.
yamlAbs := filepath.Join(partyAbs, "ssr.yaml")
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, yamlAbs, email, 0o755); err != nil { if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, yamlAbs, email, 0o755); err != nil {
slog.Warn("ssr-create: ensure canonical ancestors", "path", yamlAbs, "err", err) slog.Warn("ssr-create: ensure canonical ancestors", "path", yamlAbs, "err", err)
} }
if err := os.MkdirAll(partyAbs, 0o755); err != nil {
auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Auto-own .zddc on the new party folder. archive/*/ is declared
// auto_own in defaults.zddc.yaml, so the unfenced creator grant
// fires here exactly as it would for a manual mkdir.
if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) {
roles := zddc.AutoOwnRolesAt(cfg.Root, partyAbs)
var werr error
if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email, roles)
} else {
werr = zddc.WriteAutoOwnZddc(partyAbs, email, roles)
}
if werr != nil {
slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr)
}
}
// Drop the path-derived `name` field — it's the folder name, not // Drop the path-derived `name` field — it's the filename, not row
// row data. The dispatcher re-injects it on read. // data. The dispatcher re-injects it on read.
delete(dataMap, "name") delete(dataMap, "name")
yamlBytes, err := yaml.Marshal(dataMap) yamlBytes, err := yaml.Marshal(dataMap)
@ -252,27 +222,26 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW
return return
} }
partyAbs := filepath.Join(cfg.Root, req.Project, "archive", party) // The party must be registered (ssr/<party>.yaml exists) before rows
if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) { // can be filed for it.
http.Error(w, "Not Found", http.StatusNotFound) if !zddc.PartyRegistered(filepath.Join(cfg.Root, req.Project), "ssr", party) {
return
}
if info, err := os.Stat(partyAbs); err != nil || !info.IsDir() {
writeValidationErrors(w, []jsonschema.Error{{ writeValidationErrors(w, []jsonschema.Error{{
Path: "/party", Path: "/party",
Message: "party folder does not exist — create it via the SSR view first", Message: "unknown party — register it via the SSR view first",
}}) }})
return return
} }
slotAbs := filepath.Join(partyAbs, req.Slot) slotAbs := filepath.Join(cfg.Root, req.Project, req.Slot, party)
slotURL := "/" + req.Project + "/archive/" + party + "/" + req.Slot + "/" if !strings.HasPrefix(slotAbs, cfg.Root+string(filepath.Separator)) {
rowDirURL := slotURL // The slot folder where the new row lands. http.Error(w, "Not Found", http.StatusNotFound)
_ = rowDirURL // kept for clarity; ACL chain is gated below. return
}
slotURL := "/" + req.Project + "/" + req.Slot + "/" + party + "/"
// ACL gate: create at <project>/archive/<party>/<slot>/. authorizeAction // ACL gate: create at <project>/<peer>/<party>/. authorizeAction walks
// walks up to the closest existing ancestor (typically <party>/), where // up to the closest existing ancestor (the peer or project), where
// the auto-own .zddc grants the party owner rwcd. // document_controller carries rwcd per the cascade.
if !authorizeAction(cfg, w, r, slotAbs, slotURL, policy.ActionCreate) { if !authorizeAction(cfg, w, r, slotAbs, slotURL, policy.ActionCreate) {
return return
} }
@ -351,7 +320,7 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW
// Route through WriteWithHistory for audit stamping. The // Route through WriteWithHistory for audit stamping. The
// filename_format check inside WriteWithHistory passes because // filename_format check inside WriteWithHistory passes because
// the path we constructed above used the same composition. // the path we constructed above used the same composition.
res, verrs, herr := WriteWithHistory(cfg, target, "/"+req.Project+"/"+req.Slot+"/"+party+"__"+fname, yamlBytes, email) res, verrs, herr := WriteWithHistory(cfg, target, "/"+req.Project+"/"+req.Slot+"/"+party+"/"+fname, yamlBytes, email)
if herr != nil { if herr != nil {
auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, len(yamlBytes), herr) auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, len(yamlBytes), herr)
http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError) http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError)
@ -363,7 +332,7 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW
} }
finalBody := res.FinalBody finalBody := res.FinalBody
rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "__" + fname rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "/" + fname
w.Header().Set("Location", rowURL) w.Header().Set("Location", rowURL)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "rollup-create") w.Header().Set("X-ZDDC-Source", "rollup-create")
@ -398,8 +367,8 @@ func serveSSRRename(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
src := zddc.ResolveVirtualView(cfg.Root, r.URL.Path) srcProject, srcParty, ok := parseSSRRowURL(r.URL.Path)
if !src.Resolved || src.Kind != zddc.VirtualViewSSRRow { if !ok {
http.Error(w, "Bad Request — ssr-rename source must be /<project>/ssr/<party>.yaml", http.StatusBadRequest) http.Error(w, "Bad Request — ssr-rename source must be /<project>/ssr/<party>.yaml", http.StatusBadRequest)
return return
} }
@ -412,69 +381,81 @@ func serveSSRRename(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if dec, err := url.PathUnescape(dstHeader); err == nil { if dec, err := url.PathUnescape(dstHeader); err == nil {
dstHeader = dec dstHeader = dec
} }
dst := zddc.ResolveVirtualView(cfg.Root, dstHeader) dstProject, dstParty, ok := parseSSRRowURL(dstHeader)
if !dst.Resolved || dst.Kind != zddc.VirtualViewSSRRow { if !ok {
http.Error(w, "Bad Request — ssr-rename destination must be /<project>/ssr/<party>.yaml", http.StatusBadRequest) http.Error(w, "Bad Request — ssr-rename destination must be /<project>/ssr/<party>.yaml", http.StatusBadRequest)
return return
} }
if dst.Project != src.Project { if dstProject != srcProject {
http.Error(w, "Bad Request — ssr-rename cannot cross projects", http.StatusBadRequest) http.Error(w, "Bad Request — ssr-rename cannot cross projects", http.StatusBadRequest)
return return
} }
if dst.Party == src.Party { if dstParty == srcParty {
http.Error(w, "Bad Request — destination is the same as source", http.StatusBadRequest) http.Error(w, "Bad Request — destination is the same as source", http.StatusBadRequest)
return return
} }
// Source party folder must exist. srcAbs := filepath.Join(cfg.Root, srcProject, "ssr", srcParty+".yaml")
srcArchive := src.PartyArchive dstAbs := filepath.Join(cfg.Root, dstProject, "ssr", dstParty+".yaml")
if info, err := os.Stat(srcArchive); err != nil || !info.IsDir() { srcURL := "/" + srcProject + "/ssr/" + srcParty + ".yaml"
if err != nil && !errors.Is(err, os.ErrNotExist) { dstURL := "/" + dstProject + "/ssr/" + dstParty + ".yaml"
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return // Source registry row must exist; destination must not.
} if info, err := os.Stat(srcAbs); err != nil || info.IsDir() {
http.Error(w, "Not Found", http.StatusNotFound) http.Error(w, "Not Found", http.StatusNotFound)
return return
} }
// Destination must not exist. if _, err := os.Stat(dstAbs); err == nil {
dstArchive := dst.PartyArchive http.Error(w, "Conflict — party \""+dstParty+"\" is already registered", http.StatusConflict)
if _, err := os.Stat(dstArchive); err == nil {
http.Error(w, "Conflict — destination party folder already exists", http.StatusConflict)
return return
} else if !errors.Is(err, os.ErrNotExist) { } else if !errors.Is(err, os.ErrNotExist) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
// ACL: write on src archive, create on dst archive. URLs include // ACL: write on the old registry row, create on the new one (ssr/
// the trailing slash convention used elsewhere for directory ops. // grants document_controller rwc).
srcArchiveURL := "/" + src.Project + "/archive/" + src.Party + "/" if !authorizeAction(cfg, w, r, srcAbs, srcURL, policy.ActionWrite) {
dstArchiveURL := "/" + dst.Project + "/archive/" + dst.Party + "/"
if !authorizeAction(cfg, w, r, srcArchive, srcArchiveURL, policy.ActionWrite) {
return return
} }
if !authorizeAction(cfg, w, r, dstArchive, dstArchiveURL, policy.ActionCreate) { if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
return return
} }
// Optional If-Match against the source ssr.yaml etag. if !checkIfMatch(w, r, srcAbs) {
if !checkIfMatch(w, r, src.CanonicalAbs) {
return return
} }
if err := os.Rename(srcArchive, dstArchive); err != nil { // Registry-only rename: moves the ssr/<party>.yaml row. This does NOT
// move the party's workspace/record folders across the other peers
// (archive/, mdl/, working/, …); those keep the old name until moved
// separately. Cross-peer party rename is a deliberate later/admin op.
if err := os.Rename(srcAbs, dstAbs); err != nil {
auditFile(r, "ssr-rename", r.URL.Path, http.StatusInternalServerError, 0, err) auditFile(r, "ssr-rename", r.URL.Path, http.StatusInternalServerError, 0, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return return
} }
newURL := "/" + dst.Project + "/ssr/" + dst.Party + ".yaml" w.Header().Set("Location", dstURL)
w.Header().Set("Location", newURL) w.Header().Set("X-ZDDC-Destination", dstURL)
w.Header().Set("X-ZDDC-Destination", newURL)
w.Header().Set("X-ZDDC-Source", "ssr-rename") w.Header().Set("X-ZDDC-Source", "ssr-rename")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
resp, _ := json.Marshal(map[string]string{"location": newURL}) resp, _ := json.Marshal(map[string]string{"location": dstURL})
_, _ = w.Write(resp) _, _ = w.Write(resp)
auditFile(r, "ssr-rename", r.URL.Path, http.StatusOK, 0, nil) auditFile(r, "ssr-rename", r.URL.Path, http.StatusOK, 0, nil)
} }
// parseSSRRowURL parses /<project>/ssr/<party>.yaml into (project, party).
func parseSSRRowURL(urlPath string) (project, party string, ok bool) {
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
if len(parts) != 3 || parts[1] != "ssr" || !strings.HasSuffix(parts[2], ".yaml") {
return "", "", false
}
project = parts[0]
party = strings.TrimSuffix(parts[2], ".yaml")
if project == "" || !zddc.ValidPartyName(party) {
return "", "", false
}
return project, party, true
}

View file

@ -113,12 +113,11 @@ func DefaultProjectRskFormYAML() []byte { return embeddedDefaultProjectRskForm }
// default-spec virtual files served when no operator file exists on // default-spec virtual files served when no operator file exists on
// disk. Recognized URL shapes: // disk. Recognized URL shapes:
// //
// <project>/archive/<party>/mdl/{table.yaml, form.yaml} // <project>/mdl/{table.yaml, form.yaml} aggregate (with $party)
// <project>/archive/<party>/rsk/{table.yaml, form.yaml} // <project>/rsk/{table.yaml, form.yaml} aggregate (with $party)
// <project>/archive/<party>/ssr.form.yaml // <project>/ssr/{table.yaml, form.yaml} registry table
// <project>/ssr/{table.yaml, form.yaml} // <project>/mdl/<party>/{table.yaml, form.yaml} per-party
// <project>/mdl/{table.yaml, form.yaml} // <project>/rsk/<party>/{table.yaml, form.yaml} per-party
// <project>/rsk/{table.yaml, form.yaml}
// //
// Returns embedded bytes + true when the fallback should fire; nil + // Returns embedded bytes + true when the fallback should fire; nil +
// false when an operator file exists at that path or the URL is not // false when an operator file exists at that path or the URL is not
@ -163,14 +162,13 @@ func IsDefaultSpecAbs(fsRoot, absPath string) ([]byte, bool) {
func classifyDefaultSpec(rel string) []byte { func classifyDefaultSpec(rel string) []byte {
parts := strings.Split(rel, "/") parts := strings.Split(rel, "/")
switch len(parts) { switch len(parts) {
case 5: case 4:
// <project>/archive/<party>/<slot>/<file> // <project>/<peer>/<party>/<file> — per-party register specs
if !strings.EqualFold(parts[1], "archive") { // (mdl/<party>/, rsk/<party>/). The single-party table/form,
return nil // no $party column.
} peer := strings.ToLower(parts[1])
slot := strings.ToLower(parts[3]) file := strings.ToLower(parts[3])
file := strings.ToLower(parts[4]) switch peer {
switch slot {
case "mdl": case "mdl":
switch file { switch file {
case "table.yaml": case "table.yaml":
@ -186,19 +184,13 @@ func classifyDefaultSpec(rel string) []byte {
return embeddedDefaultRskForm return embeddedDefaultRskForm
} }
} }
case 4:
// <project>/archive/<party>/<file> — only ssr.form.yaml is virtual.
if !strings.EqualFold(parts[1], "archive") {
return nil
}
if strings.EqualFold(parts[3], "ssr.form.yaml") {
return embeddedDefaultSsrForm
}
case 3: case 3:
// <project>/<slot>/<file> — project-level virtual specs. // <project>/<peer>/<file> — peer-root specs. ssr/ is a flat
slot := strings.ToLower(parts[1]) // register; mdl/ and rsk/ are the cross-party AGGREGATE tables
// (the project-level spec carries the $party column).
peer := strings.ToLower(parts[1])
file := strings.ToLower(parts[2]) file := strings.ToLower(parts[2])
switch slot { switch peer {
case "ssr": case "ssr":
switch file { switch file {
case "table.yaml": case "table.yaml":

View file

@ -1,31 +1,23 @@
// Package handler — virtualviewhandler.go: GET dispatch for SSR row + // Package handler — virtualviewhandler.go: GET dispatch for the
// MDL/RSK rollup row URLs. // aggregate register rows (mdl/<party>/*.yaml, rsk/<party>/*.yaml,
// ssr/<party>.yaml).
// //
// These URLs live in the project-level virtual folders (<project>/ssr, // In the flat-peer layout these are REAL files at their own paths; the
// <project>/mdl, <project>/rsk) and rewrite to canonical files inside // only thing the on-disk bytes lack is the path-derived source column
// <project>/archive/<party>/. The bytes returned to the client are // the aggregate table renders:
// augmented with a single path-derived field that the canonical file
// doesn't carry:
// //
// - SSR rows get `name: <party>` so the table renderer has a column // - MDL / RSK rows get `$party: <party>` so the cross-party rollup
// to sort on and the form edit pre-fills the party name. (Identity // can show which party each row came from. The `$` sigil marks the
// of an SSR row is the party folder name, so the field is named // field system-synthesised: the tables tool renders it read-only and
// plainly rather than sigil-prefixed.) // the form client strips it before submit, so a user-defined `party`
// - MDL / RSK rollup rows get `$party: <party>` so the rollup table // field never collides with the synthetic source-party column.
// can show which package each row came from. The `$` sigil marks // - SSR rows get `name: <party>` (the party = the filename) so the
// the field as system-synthesised: tables tool renders it read- // register table has an identity column to sort on and the form edit
// only and the form client strips it before submit, so a user- // pre-fills the party name.
// defined `party` field on a deliverable row never collides with
// the synthetic source-party column.
// //
// Both fields are stripped before write-back (SSR via serveFormCreateSSR // Both fields are path-derived and stripped before write-back by the
// strip; MDL/RSK rollup writes go through the generic serveFormUpdate, // tables/form JS (the schema's additionalProperties:false also rejects
// where the path-derived `$party:` is rejected by `additionalProperties: // `$party` on submit). Listings: see fs/tree.go.
// false` in the underlying schema — so the client must strip it on
// submit, which the tables/form JS already does for path-derived
// fields).
//
// Listings: see fs/tree.go.
package handler package handler
@ -34,70 +26,45 @@ import (
"os" "os"
"strconv" "strconv"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// ServeVirtualViewRow serves a GET (or HEAD) for one of the virtual // ServeInjectedRow serves a GET/HEAD for a real register row file with a
// row URLs. Caller is expected to have already evaluated ACL against // single path-derived field injected (field=value). Used by dispatch for
// vv.PartyArchive's chain. // the aggregate register rows; ACL is evaluated by the caller against the
// // file's own chain. Returns 404 if the file doesn't exist.
// For SSR rows: returns the canonical archive/<party>/ssr.yaml bytes func ServeInjectedRow(w http.ResponseWriter, r *http.Request, abs, field, value string) {
// with `name: <party>` injected. If no canonical file exists yet, raw, err := os.ReadFile(abs)
// returns `name: <party>\n` (an otherwise-empty row) — the SSR view
// shows every party folder whether or not metadata has been written.
//
// For MDL / RSK rollup rows: returns the canonical bytes with
// `party: <party>` injected. If the canonical file doesn't exist
// (shouldn't happen — the listing only surfaces real files) returns
// 404.
func ServeVirtualViewRow(w http.ResponseWriter, r *http.Request, vv zddc.VirtualViewResolution) {
if !vv.Resolved || !vv.Kind.IsRowKind() {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
raw, err := os.ReadFile(vv.CanonicalAbs)
if err != nil { if err != nil {
if !os.IsNotExist(err) { if os.IsNotExist(err) {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// File doesn't exist yet.
if vv.Kind != zddc.VirtualViewSSRRow {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
raw = nil http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
} }
var data map[string]any var data map[string]any
if len(raw) > 0 { if len(raw) > 0 {
if err := yaml.Unmarshal(raw, &data); err != nil { if err := yaml.Unmarshal(raw, &data); err != nil {
http.Error(w, "parse canonical yaml: "+err.Error(), http.StatusInternalServerError) http.Error(w, "parse row yaml: "+err.Error(), http.StatusInternalServerError)
return return
} }
} }
if data == nil { if data == nil {
data = make(map[string]any) data = make(map[string]any)
} }
switch vv.Kind { data[field] = value
case zddc.VirtualViewSSRRow:
data["name"] = vv.Party
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
data["$party"] = vv.Party
}
out, err := yaml.Marshal(data) out, err := yaml.Marshal(data)
if err != nil { if err != nil {
http.Error(w, "marshal virtual row: "+err.Error(), http.StatusInternalServerError) http.Error(w, "marshal row: "+err.Error(), http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "application/yaml; charset=utf-8") w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-ZDDC-Source", "virtual-view-row") w.Header().Set("X-ZDDC-Source", "register-row")
w.Header().Set("X-ZDDC-Resolved-Path", vv.CanonicalURL)
w.Header().Set("Content-Length", strconv.Itoa(len(out))) w.Header().Set("Content-Length", strconv.Itoa(len(out)))
if r.Method == http.MethodHead { if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

View file

@ -406,6 +406,7 @@ func nonZeroZddcFields(zf ZddcFile) []string {
add("auto_own_fenced", zf.AutoOwnFenced != nil) add("auto_own_fenced", zf.AutoOwnFenced != nil)
add("virtual", zf.Virtual != nil) add("virtual", zf.Virtual != nil)
add("drop_target", zf.DropTarget != nil) add("drop_target", zf.DropTarget != nil)
add("party_source", zf.PartySource != "")
add("history", zf.History != nil) add("history", zf.History != nil)
add("history_globs", len(zf.HistoryGlobs) > 0) add("history_globs", len(zf.HistoryGlobs) > 0)
add("worm", zf.Worm != nil) add("worm", zf.Worm != nil)

View file

@ -23,72 +23,43 @@ acl:
# ── Standard roles ───────────────────────────────────────────────────────── # ── Standard roles ─────────────────────────────────────────────────────────
# #
# Three roles ship empty (no members) — a fresh deployment grants # Three roles ship empty (no members) — a fresh deployment grants
# nothing until an operator populates them. They're referenced by the # nothing until an operator populates them. Membership UNIONS across
# project-scoped grants in paths: below. # the cascade; use `reset: true` at a subtree to start fresh.
# #
# Role membership UNIONS across the cascade: an on-disk .zddc that # document_controller — owns the committed record and the party
# defines `project_team` again with one extra member ADDS that member # registry. They:
# to the inherited role. To start fresh at a subtree (e.g. a project # - register parties: a party EXISTS iff ssr/<party>.yaml exists,
# wanting its own team independent of a deployment-wide default), use # and the DC creates it (rwc at ssr/). This is the single
# `reset: true` on the role at that level — ancestor definitions above # source of truth for party existence.
# the reset are then excluded. # - file write-once into the WORM archive: read + create at
# archive/<party>/received and issued via the worm: list (the
# WORM mask strips w/d/a; create survives only for listed
# principals). archive/ also grants rwc so the DC can create
# party record dirs.
# - rwcda across the live workspaces (incoming/working/staging/
# reviewing), restated per-peer so a DC matched by the
# project_team wildcard keeps full authority via within-level
# union.
# NOT a subtree-admin anywhere — no admins: entry. DCs cannot
# bypass WORM (only worm-create); admin elevation is reserved for
# the root admins: list (the human escape hatch for mis-filed
# documents or recovery).
# #
# document_controller — the people who file into # project_team — everyone working on a project. Read across the
# archive/<party>/received/ and issued/ (WORM zones). They get: # project, with a one-way ratchet through the live workspaces:
# - rwcda at every archive/<party>/ via the role grant written # working/ cr create + read; auto_own gives the creator
# into each party's auto-own .zddc (auto_own_roles below). # rwcda inside the party folder they make
# Cascade carries rwcda down to descendants by default. # staging/ cr drop + read, no modify after the drop
# - read+write-once-create at received/issued via the worm: # reviewing/ cr create + read review iterations
# lists (the WORM mask strips w/d/a even though the role # incoming/ r counterparty's drop zone (observe)
# grant supplies rwcda at the party level above). # archive/ r the committed record (received/issued), WORM
# - rwcd explicit at incoming/ and staging/ (the QC and # ssr/mdl/rsk r registry + registers (the DC maintains them)
# transmittal-out workflows need `d` to move files between # Each handoff drops the role's modify rights for the previous
# slots; the explicit grants shadow the inherited rwcda # stage.
# to make the intent visible).
# - rwc at archive/ so they can create party subfolders.
# #
# NOT a subtree-admin anywhere. There is no `admins:` entry for # observer — pure read-only across the project; no create anywhere.
# the role — DCs cannot bypass WORM (only worm-create via the # Intended for auditors, regulators, and external read-only
# list) and cannot reach inside fenced working homes. Admin # viewers who must not contribute content.
# elevation is reserved for the root admins: list (the human
# escape hatch for mis-filed documents or recovery).
#
# Plan-Review approval is part of this role by design — there is
# no separate `approver` role; two-person sign-off, when needed,
# is expressed via per-folder `.zddc` overrides rather than
# baked-in roles.
#
# project_team — everyone working on a project. Read-only across
# the project by default, with a one-way ratchet through the
# in-flight slots:
#
# working/ cr — create + read; the auto_own_fenced child
# gives the creator rwcda in their own home,
# fenced from siblings
# staging/ cr — drop + read, no modify (after drop, the
# doc-controller is the only one who can
# change it)
# reviewing/ cr — create + read; auto_own (unfenced) gives
# creator rwcda in their iteration folder,
# siblings see it via project-level :r
# received/ r — WORM zone; only document_controller can
# file (and even they need elevation to edit)
# issued/ r — WORM zone; published, immutable
# incoming/ r — counterparty's drop zone (project_team
# observers it, doc_controller QCs it)
#
# "Each handoff drops the role's modify rights for the previous
# slot." That's the model — project_team works freely in
# working/, commits to staging/, and from there the doc-
# controller takes over.
#
# observer — pure read-only across the project. Like project_team
# but with no auto-own home: an observer who somehow created a
# working/<email>/ would still own it via auto-own (the mechanism
# is path-keyed, not role-keyed), but since observer lacks `c`
# anywhere, the situation doesn't arise in practice. Intended for
# auditors, regulators, and external read-only viewers who must
# not contribute content.
roles: roles:
document_controller: document_controller:
members: [] members: []
@ -99,481 +70,243 @@ roles:
# Universal tool baseline. archive (record browser), browse (file # Universal tool baseline. archive (record browser), browse (file
# tree, hosts the in-place markdown editor), and landing (project # tree, hosts the in-place markdown editor), and landing (project
# picker) work everywhere. Each canonical folder below adds its own # picker) work everywhere. Each peer below adds its own tools
# context-specific tools (transmittal in staging/, etc.). The cascade # (transmittal in staging/, tables in mdl/rsk/ssr, etc.). available_tools
# unions available_tools across all levels — leaf restrictions don't # UNIONS across the cascade — leaf restrictions don't drop ancestor
# drop ancestor entries — so this baseline propagates to every # entries — so this baseline propagates to every descendant.
# descendant.
available_tools: [archive, browse, landing] available_tools: [archive, browse, landing]
# ── The slash / no-slash routing convention ──────────────────────────────── # ── The slash / no-slash routing convention ────────────────────────────────
# #
# Every directory URL has two forms, each served by a configurable # Every directory URL has two forms:
# tool:
# #
# <dir>/ (trailing slash) → `dir_tool` — the directory view. # <dir>/ (trailing slash) → `dir_tool` — the directory view
# Defaults to `browse` (file-tree # (defaults to `browse`, the file-tree
# navigator). This is the site-wide # navigator; you rarely set it).
# default; you rarely set it. # <dir> (no slash) → `default_tool` — the specialized app
# <dir> (no slash) → `default_tool` — the "specialized # for this folder (archive, transmittal,
# app" for this folder (e.g. archive, # tables). If a folder declares no
# transmittal, tables). If a folder # default_tool, the no-slash form 302s
# declares no default_tool, the no- # to the slash form.
# slash form just 302s to the slash
# form, so you land on `dir_tool`.
# #
# JSON listing requests are unaffected by either key — they always get # JSON listing requests are unaffected — they always get the raw
# the raw directory listing, so the browse SPA (and any other client) # directory listing, so the browse SPA (and any client) can enumerate
# can enumerate entries no matter what dir_tool/default_tool are. # entries regardless of dir_tool/default_tool. Both keys cascade
# leaf→root.
# #
# Both keys cascade leaf→root: a parent's default_tool applies to # ── Canonical project structure (top-level party peers) ─────────────────────
# descendants unless a deeper level overrides it (browse set on
# working/ reaches working/alice/notes/ for free). The keys below set
# default_tool on the canonical folders; dir_tool is left unset
# everywhere, so the slash form is always `browse`.
# #
# ── Canonical project structure ──────────────────────────────────────────── # A project is a top-level directory. Under it sit a FLAT set of
# physical, party-partitioned peers — there are no virtual aggregators:
# #
# Every ZDDC project lives at a top-level directory. Under it # archive/<party>/{received,issued}/ the committed record. PURE
# `archive/` is the ONLY real top-level folder; it contains a folder # WORM (one rule on archive/, no
# per party. Everything party-scoped (the SSR row, MDL/RSK rollups, # exceptions): write/delete
# WORM received/issued, the incoming drop zone, and the in-flight # stripped for all; create only
# lifecycle slots working/staging/reviewing) lives uniformly under # for document_controller (the
# archive/<party>/. # worm: list); admins bypass.
# Party record dirs appear on the
# first filing.
# incoming/<party>/ counterparty drop zone
# reviewing/<party>/<tracking>/ we review their submission
# working/<party>/ our drafts (edit-history on)
# staging/<party>/<tracking>/ assemble transmittals
# mdl/<party>/*.yaml master document list (tables)
# rsk/<party>/*.yaml risk register (tables)
# ssr/<party>.yaml submittal status register — AND
# the AUTHORITATIVE PARTY REGISTRY
# #
# Six top-level virtuals sit beside archive/ as resolver views: # Party registry: `ssr/<party>.yaml` existence is the SINGLE source of
# truth for "party <party> exists". Creating it (rwc at ssr/, via the
# SSR form) is how a party is born. Every OTHER peer carries
# `party_source: ssr`, so you cannot create <peer>/<party>/… — archive
# filing included — until the ssr row exists; the server 409s otherwise.
# ssr/ itself has no party_source (it is the source).
# #
# ssr mdl rsk tables rollups across parties # mdl/ and rsk/ AGGREGATE: the peer root renders ALL parties in one
# (with a synthesized $party column) # table (a $party column derived from the real subdir), <peer>/<party>/
# working staging browse folder-nav listings of # shows that party's rows. ssr/ aggregates naturally (one flat file per
# reviewing parties with non-empty content in # party). $party is a real directory level, not a synthesized column.
# the slot (in-flight filter). The
# virtual 302-redirects to the
# canonical archive/<party>/<slot>/.
# #
# Mkdir at the project root is restricted to `archive` plus system # Mkdir at the project root is restricted to the peer names above plus
# (_/.-prefixed) names; the six virtual aggregator names are rejected # system (_/.-prefixed) names (see handler/fileapi.go). Nothing here
# because the virtual would shadow any physical folder created at # needs to exist on disk — the cascade resolves behaviour so a fresh
# those URLs (see handler/fileapi.go). # project lands on usable empty views at every well-known URL. Operators
# # override by mirroring this structure in an on-disk .zddc.
# Everything below is expressed via the recursive paths: schema. None
# of the directories need to exist on disk — the cascade walker
# resolves behaviour from this declaration, so a fresh project lands
# on usable empty views at every well-known URL.
#
# Operators override any of this by mirroring the structure in an
# on-disk .zddc and changing what they need; on-disk values win.
paths: paths:
# First segment under root is the project name; "*" matches any. # First segment under root is the project name; "*" matches any.
"*": "*":
# Project-scoped baseline ACL. project_team and observer get read # Project-scoped baseline ACL. project_team and observer read across
# across the project; document_controller gets read + overwrite- # the project; document_controller gets read + overwrite-existing.
# existing (so people can ask them to fix a stuck file). None of # None gets `c` here — create is granted only at the specific peers
# the three gets `c` (create) at this level — that's granted only # below (archive/, ssr/, and the workspaces).
# at the specific spots below (archive/, working/, staging/), so
# the doc controller can't make arbitrary folders. Grants here cap
# at deeper levels per deepest-match-wins, except where a deeper
# .zddc restates a fuller grant for the same principal.
acl: acl:
permissions: permissions:
project_team: r project_team: r
observer: r observer: r
document_controller: rw document_controller: rw
paths: paths:
# ── Top-level virtual aggregators ─────────────────────────── # ── The committed record: pure WORM ─────────────────────────
#
# Six resolver views, sibling to archive/. None of these
# materialise on disk; the server synthesises listings by
# walking archive/*/<slot>/ at request time and (for the
# tables rollups) rewrites file reads/writes back to canonical
# paths inside the per-party folders. ACL on each synthetic
# row is evaluated against the canonical archive/<party>/
# chain, so party owners can edit their own rows and non-
# owners see them read-only.
ssr:
default_tool: tables
available_tools: [tables]
virtual: true
mdl:
default_tool: tables
available_tools: [tables]
virtual: true
# Edit-history default-on for the deliverables list (subtree-
# inheriting; see working/ note). Operators override per .zddc.
history: true
rsk:
default_tool: tables
available_tools: [tables]
virtual: true
# Edit-history default-on for the risk register.
history: true
working:
default_tool: browse
available_tools: [browse]
# Pure folder-nav aggregator over the per-party
# archive/<party>/working/ slots — same shape as staging/ and
# reviewing/ below. Nothing lives directly at <project>/working/:
# creating a folder here prompts for a party (browse's "New
# folder" picker) and lands it at archive/<party>/working/<name>,
# which carries its own history: true + auto-own convention.
virtual: true
# Edit-history default-on across the working subtree (markdown
# saves are snapshotted to .history/<stem>/). Subtree-inheriting,
# so it also covers any pre-reshape <project>/working/<…> homes
# that still hold content. Reads of recorded history never require
# this flag — turning it off only stops new snapshots.
history: true
staging:
default_tool: browse
available_tools: [browse]
virtual: true
reviewing:
default_tool: browse
available_tools: [browse]
virtual: true
# ── Physical party root ─────────────────────────────────────
archive: archive:
default_tool: archive default_tool: archive
# The doc controller can create party subfolders here # A record can only be filed for a registered party.
# (archive/<party>/). Restate the full grant — deepest-match party_source: ssr
# is per-principal replacement, so we re-list rw + add c. # The ONE WORM rule. Cascades to <party>/{received,issued}:
# write/delete stripped for everyone; create survives only for
# document_controller; admins bypass (the escape hatch).
worm: [document_controller]
# rwc so a DC can create party record dirs (WORM masks w/d to
# leave read + write-once-create).
acl: acl:
permissions: permissions:
document_controller: rwc document_controller: rwc
paths:
# Second segment under archive/ is the party name. # ── Authoritative party registry + submittal status register ─
"*": ssr:
# When the doc controller creates a party folder, the default_tool: tables
# auto-own .zddc grants: available_tools: [tables]
# - the creator's email rwcda (the standard auto_own # NO party_source — ssr/ IS the source of party existence.
# mechanism) # rwc: a DC registers a party by creating ssr/<party>.yaml and
# - the document_controller role rwcda (auto_own_roles # maintains its status (overwrite). Delete (de-register) is left
# below) so any DC in the role has full authority at # to admins so a party with archived records is never orphaned.
# every party, not just the parties they personally acl:
# mkdir'd permissions:
# document_controller: rwc
# UNFENCED — so the project-level project_team:r still history: true
# cascades through to received/issued/incoming. That
# lets the DC who created the party set up the counter-
# party's own .zddc afterward (e.g. granting them cr at
# incoming/).
#
# No `admins:` here by design. The DC role gets full
# authority via the role grant in the auto-own .zddc, not
# via subtree-admin status — so WORM masks at
# received/issued still bind them (they file write-once
# via the worm: list), and per-user fenced homes under
# working/ stay private to their creators. Admin
# elevation is reserved for the root admins list (the
# actual sudo-style escape hatch).
auto_own: true
auto_own_roles: [document_controller]
# SSR record: the party folder's ssr.yaml carries this
# party's vendor / contract / status data. Scoped by
# filename pattern so the lock on `kind` only applies to
# ssr.yaml — the mdl/, rsk/, received/, working/,
# staging/, reviewing/ subfolders are untouched. No
# filename_format because identity is the party folder
# name, not a composed tracking number.
records: records:
"ssr.yaml": "*.yaml":
field_defaults: field_defaults:
kind: SSR kind: SSR
locked: [kind] locked: [kind]
# ── Field-code vocabularies (field_codes:) ──────────────
# Each tracking-number component can be constrained by a # ── Inbound workspace: counterparty drop zone ───────────────
# field_codes entry at this (per-party) level — or higher incoming:
# if every party shares the same vocabulary. Three kinds: default_tool: classifier
# available_tools: [classifier]
# enum — closed code list; the label surfaces in form party_source: ssr
# dropdowns and is enforced on POST/PUT. # The other party's DC uploads here (a deployment grants them
# pattern — anchored regex (server wraps it with ^…$). # cr, e.g. acl: { permissions: { "*@acme.com": cr } } at
# free — no constraint; `description:` is help-text in # incoming/Acme/.zddc); OUR DC QCs via classifier and moves to
# the form UI. # archive/<party>/received. project_team has read only (observe).
# acl:
# Map-merge across the cascade: a deeper .zddc can narrow permissions:
# or replace a single code without re-listing the others. document_controller: rwcd
# `originator` is normally NOT listed here — it's bound to
# the party-folder name via folder_fields on the mdl/ + rsk/
# records below, so the folder is its sole source of truth.
#
# field_codes:
# discipline:
# kind: enum
# codes:
# EL: "Electrical"
# ME: "Mechanical"
# CV: "Civil"
# sequence:
# kind: pattern
# pattern: "[0-9]{4}" # zero-padded 4-digit
# type:
# kind: free
# description: "Document category code within the discipline"
paths: paths:
mdl: "*": # incoming/<party>
default_tool: tables auto_own: true
available_tools: [tables] drop_target: true
# The mdl folder is virtual by convention — the
# tables tool serves it from the embedded default # ── Inbound workspace: review of their submission ───────────
# spec even when the on-disk folder doesn't exist. reviewing:
virtual: true default_tool: browse
# Edit-history default-on (markdown notes/specs saved here available_tools: [browse]
# are snapshotted; .yaml records keep their own record- party_source: ssr
# history path regardless). # The Plan-Review composite endpoint scaffolds a folder here per
# submittal under review, with a .zddc carrying received_path
# back to the canonical record in archive/<party>/received.
acl:
permissions:
project_team: cr
document_controller: rwcda
paths:
"*": # reviewing/<party>
auto_own: true
drop_target: true
# ── Outbound workspace: our drafts (edit-history on) ────────
working:
default_tool: browse
available_tools: [browse, classifier]
party_source: ssr
# Subtree-inheriting: every markdown save under working/ is
# snapshotted to .zddc.d/history/<stem>/ with a server-stamped
# audit line. Reads of recorded history never require this flag.
history: true history: true
# MDL records: each .yaml file is an independent acl:
# deliverable with its own composed tracking number. permissions:
# No type lock — the row's body fields drive the project_team: cr
# filename; type is free-choice from the deployment's document_controller: rwcda
# field_codes (see the field_codes block above). paths:
"*": # working/<party> — auto-owned by its creator
auto_own: true
drop_target: true
# ── Outbound workspace: assemble transmittals ───────────────
staging:
default_tool: transmittal
available_tools: [transmittal, classifier]
party_source: ssr
# project_team drops files (cr); after the drop the doc-control
# workflow owns it. DC gets rwcda — `d` for the cut to issued/,
# `a` so Plan Review can write staging/<tracking>/.zddc.
acl:
permissions:
project_team: cr
document_controller: rwcda
paths:
"*": # staging/<party>
auto_own: true
drop_target: true
# ── Master document list (aggregates across parties) ────────
mdl:
default_tool: tables # peer root: all-parties table
available_tools: [tables]
party_source: ssr
history: true
# The DC maintains the deliverables register (create/edit/delete
# rows). project_team reads it (inherited from the project level).
acl:
permissions:
document_controller: rwcd
# field_codes: constrain tracking-number components here (or
# higher in the cascade). Three kinds — enum / pattern / free;
# map-merge across levels. originator is folder-bound (below),
# so it is not listed here. Example:
# field_codes:
# discipline: { kind: enum, codes: { EL: Electrical, ME: Mechanical } }
# sequence: { kind: pattern, pattern: "[0-9]{4}" }
paths:
"*": # mdl/<party>: that party's rows, flat
default_tool: tables
# MDL records: each .yaml is an independent deliverable with
# its own composed tracking number. originator is the party
# folder (the record's own dir, distance 0 above
# mdl/<party>/<file>.yaml) and renders read-only — the folder
# is the single source of truth for the originator code.
# #
# Default template — five required components plus an # To add project-wide components (phase, area, …), override
# optional per-deliverable suffix that marks parts of # filename_format here AND mdl/<party>/{form,table}.yaml.
# the SAME deliverable (A = Appendix A, 01 = Sheet 1):
#
# originator-project-discipline-type-sequence[-suffix]
#
# `originator` is folder-bound: the server sets it from
# the party-folder name (folder_fields below) and the
# form renders it read-only — the party folder is the
# single source of truth for the originator code.
#
# To add PROJECT-WIDE components (phase, area, ...),
# override filename_format here AND add matching
# properties to mdl/form.yaml + columns to mdl/table.yaml.
# Pick once per project and apply to EVERY deliverable;
# mixing schemas within one project breaks lexical sort
# and filtering. Example:
# records:
# "*.yaml":
# folder_fields: { originator: 1 }
# filename_format: "{originator}-{phase}-{project}-{area}-{discipline}-{type}-{sequence}-{suffix?}"
records: records:
"*.yaml": "*.yaml":
folder_fields: folder_fields:
originator: 1 originator: 0
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}" filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}"
# ── Risk register (aggregates across parties) ───────────────
rsk: rsk:
default_tool: tables default_tool: tables
available_tools: [tables] available_tools: [tables]
# Risk register — same virtual-by-convention pattern party_source: ssr
# as mdl/. Embedded default-rsk spec backs it when no
# operator override is on disk.
virtual: true
# Edit-history default-on (same as mdl/).
history: true history: true
# RSK records: each .yaml file is a row of a parent acl:
# rsk-type deliverable. The table itself has a tracking permissions:
# number (same default components as an MDL deliverable document_controller: rwcd
# with type=RSK); rows append a -{row} suffix the server paths:
# auto-assigns within the row-scope group on POST-create. "*": # rsk/<party>
# `originator` is folder-bound to the party folder, same default_tool: tables
# as MDL. # RSK records: each .yaml is a row of a parent rsk-type
# # deliverable; the server auto-assigns -{row} within the
# To add project-wide phase / area components, override # row-scope group on POST-create. originator is folder-bound
# BOTH filename_format AND row_scope_fields here — the # to the party folder, same as MDL.
# scope fields decide which rows share a row-number
# sequence, so they must list the same components the
# filename does.
records: records:
"*.yaml": "*.yaml":
folder_fields: folder_fields:
originator: 1 originator: 0
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}-{row}" filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}-{row}"
field_defaults: field_defaults:
type: RSK type: RSK
locked: [type] locked: [type]
row_field: row row_field: row
row_scope_fields: [originator, project, discipline, type, sequence, suffix] row_scope_fields: [originator, project, discipline, type, sequence, suffix]
incoming:
# incoming/ is the COUNTERPARTY's drop zone. The flow:
# 1. the other party's document controller uploads
# files here (a deployment grants them cr at this
# path, e.g. acl: { permissions: { "*@acme.com": cr } }
# at archive/Acme/incoming/.zddc — or they mkdir a
# dated subfolder under incoming/ and own it via
# auto_own)
# 2. OUR document controller QCs them via classifier
# (rename in place) and moves them to received/
# (which needs delete here + worm-create there),
# ideally returning a signed transmittal in issued/
#
# The normal project_team has only read here (inherited
# from the project level — they have no c/w) so they can
# see what's been dropped but not touch it. The
# document_controller grant restates rwcd so the QC +
# transfer-out workflow has the delete bit it needs.
default_tool: classifier
available_tools: [classifier]
auto_own: true
drop_target: true
acl:
permissions:
document_controller: rwcd
# received/ and issued/ are WORM (write-once-read-many).
# The `worm:` list marks the zone:
#
# - write (w) and delete (d) are stripped for EVERYONE
# - create (c) is stripped for everyone EXCEPT the
# principals listed — they get read + write-once-
# create ("cr")
# - read for non-listed principals is whatever the
# normal cascade ACL granted; the WORM list does not
# itself confer read to outsiders
# - admins (root / subtree) bypass entirely — the
# human escape hatch for mis-filed documents
#
# The baseline is an empty list: WORM zone, no
# create-capable principals — filing is locked until a
# deployment names a document controller, e.g.
#
# worm: ["doc-control@example.com"]
#
# at received/ (or issued/, or archive/<party>/, or
# wherever scopes it right). worm: lists UNION across the
# cascade, so a deeper .zddc adds more controllers.
received:
default_tool: archive
# document_controller may file write-once into the
# WORM zone. Their project-level rw is masked here
# to r; worm: restores write-once-create.
worm: [document_controller]
issued:
default_tool: archive
worm: [document_controller]
# ── In-flight lifecycle slots (NEW — nested per-party) ────
#
# working/staging/reviewing now live inside each party
# folder instead of at the project root. The project-
# level <project>/{working,staging,reviewing} virtuals
# (declared above) are folder-nav views over these
# canonical per-party slots.
# ── In-flight ratchet ───────────────────────────────
#
# The lifecycle slots form a one-way handoff:
#
# working/ → staging/ → issued/ (WORM)
# (full) (cr) (worm cr)
#
# At each step the previous role's modify rights drop:
# project_team iterates freely in working/; when they
# promote to staging/ they can't change it without doc-
# controller help; when DC publishes to issued/ even
# they can't change it without elevation. Each ACL
# grant below is the verb-set the ROLE keeps at that
# step; auto_own + auto_own_fenced sub-folder grants
# layer per-creator ownership on top of these.
working:
default_tool: browse
available_tools: [browse, classifier]
# Project_team gets read + create here so they can
# mkdir their own home folder (and any shared sub-
# folders). The auto_own_fenced declaration at the
# `*` child below makes the new folder a private home
# with rwcda for the creator (fenced from ancestors,
# so collaborators only join after the owner edits
# the home's .zddc to grant them access).
#
# `cr` instead of just `c` so an existing file at
# working/ root stays readable to all team members
# (cascade is per-level deepest-match — a single `c`
# would shadow the project-level `r`).
#
# `document_controller: rwcda` is restated here so a
# DC whose email is ALSO matched by project_team
# (typical when project_team is `*@example.com`) gets
# the higher grant via within-level union. Without
# the restatement, the cascade's deepest-level-wins
# would pick project_team's cr and shadow the DC's
# rwcda inherited from the party's auto-own .zddc.
# Same pattern applied at staging/ and reviewing/.
acl:
permissions:
project_team: cr
document_controller: rwcda
# working/ auto-owns the first creator + the per-user
# homes below.
auto_own: true
drop_target: true
# Edit-history: every markdown save under working/ (incl.
# the fenced per-user homes — history inherits through
# fences) is versioned into a sibling .history/ store with
# a server-stamped audit line (who + when). The live file
# stays the source of truth; GET <file>?history lists prior
# versions. See ZddcFile.History / handler.WriteTextWithHistory.
history: true
paths:
"*": # per-user home dir, fenced
default_tool: browse
available_tools: [browse, classifier]
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
drop_target: true
staging:
default_tool: transmittal
available_tools: [transmittal, classifier]
# The ratchet step from working/. project_team gets
# `cr` — they can drop files (PUT new files at
# staging/) and read what's there, but cannot edit or
# delete after the drop. Once a file is in staging it
# belongs to the doc-controller workflow; the team
# member needs to ask DC to change it.
#
# Convention: project_team drops FILES at staging/,
# not sub-folders. A sub-folder mkdir'd by project_
# team would trigger auto_own and grant them rwcda
# inside their own sub-folder (auto_own is path-keyed,
# not role-keyed — it fires for any creator). The
# auto_own here is preserved for DC's per-transmittal
# mkdir flow; project_team can keep to file drops to
# honour the "can't alter after" intent.
#
# DC gets rwcda explicitly — `d` for the cut to issued/,
# `a` so Plan Review can write the staging/<tracking>/.zddc
# the composite endpoint scaffolds. Restated here (not
# inherited from the party-level role grant) so the
# within-level union dominates project_team's cr for
# any DC matched by the team wildcard.
acl:
permissions:
project_team: cr
document_controller: rwcda
auto_own: true
drop_target: true
reviewing:
default_tool: browse
available_tools: [browse]
# reviewing/ is the doc-controller's draft-workspace
# area inside this party folder. The "Plan Review"
# composite endpoint scaffolds a physical folder here
# for each submittal under review, with a .zddc
# carrying received_path back to the canonical
# submittal in received/. Subtree-admin (inherited
# from the party-level admins:) so the doc
# controller can author per-folder .zddc files
# (originator ACL, planned_date).
#
# project_team gets `cr` so the originating team can
# create review-iteration folders alongside the
# Plan-Review-scaffolded ones. auto_own (unfenced
# here, unlike working/) gives the creator rwcda
# inside; siblings see the iteration via the project-
# level project_team:r cascade.
#
# document_controller: rwcda restated for the same
# reason as working/ + staging/ — keeps a DC matched
# by the project_team wildcard at full authority via
# within-level union.
acl:
permissions:
project_team: cr
document_controller: rwcda
auto_own: true
drop_target: true

View file

@ -51,7 +51,7 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
if len(parts) >= 2 { if len(parts) >= 2 {
seg := strings.ToLower(parts[1]) seg := strings.ToLower(parts[1])
if seg == "archive" { if IsProjectPeer(seg) {
if err := resolveAt(1, seg); err != nil { if err := resolveAt(1, seg); err != nil {
return target, err return target, err
} }
@ -70,10 +70,10 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
// EnsureCanonicalAncestors walks from fsRoot down to filepath.Dir(target), // EnsureCanonicalAncestors walks from fsRoot down to filepath.Dir(target),
// creating any missing canonical-folder ancestor with MkdirAll(perm). // creating any missing canonical-folder ancestor with MkdirAll(perm).
// For freshly-created auto-own ancestors (archive/<party>/, and the per- // For freshly-created auto-own ancestors (the workspace party folders),
// party lifecycle slots {working,staging,reviewing,incoming}), it also // it also writes a creator-owned .zddc using principalEmail (skipped if
// writes a creator-owned .zddc using principalEmail (skipped if // principalEmail is empty) — auto-own + fence are resolved per-dir via
// principalEmail is empty). // the .zddc cascade (AutoOwnAt / AutoOwnFencedAt).
// //
// Returns the resolved version of target with on-disk casing substituted // Returns the resolved version of target with on-disk casing substituted
// for any canonical ancestor whose disk variant differs from the requested // for any canonical ancestor whose disk variant differs from the requested
@ -82,14 +82,12 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
// //
// Canonical positions, relative to fsRoot: // Canonical positions, relative to fsRoot:
// //
// - <project>/archive (the only physical project-root canonical; // - <project>/<peer> for any top-level peer (IsProjectPeer: archive,
// working/staging/reviewing/ssr/mdl/rsk at project root are virtual // incoming, working, staging, reviewing, mdl, rsk, ssr) — all are
// aggregators with no on-disk presence — writes targeting them // physical directories.
// must be rejected by the caller's project-root mkdir guard.)
// //
// - <project>/archive/<party>/<canonical-party> where // - <project>/archive/<party>/<slot> where <slot> ∈ {received, issued}
// <canonical-party> ∈ {mdl, rsk, incoming, received, issued, // (IsPerPartySlot) — the WORM record folders.
// working, staging, reviewing}
// //
// fsRoot and target must be absolute filesystem paths under the same // fsRoot and target must be absolute filesystem paths under the same
// volume; target may not yet exist on disk. // volume; target may not yet exist on disk.
@ -109,16 +107,6 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
return target, nil return target, nil
} }
// Reject writes targeting top-level virtual aggregators —
// <project>/{ssr,mdl,rsk,working,staging,reviewing}/... — these
// resolve through ResolveVirtualView, not as physical paths. A
// caller writing under them bypassed the virtual resolver; the
// content belongs under archive/<party>/<slot>/ (browse's "New
// folder" picker prompts for the party).
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)) resolvedSegs := make([]string, len(parts))
copy(resolvedSegs, parts) copy(resolvedSegs, parts)
@ -161,18 +149,17 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
// Walk depth 1 (project) → deeper levels, resolving + tracking as we go. // Walk depth 1 (project) → deeper levels, resolving + tracking as we go.
// Depth 0 is the project segment; not a canonical name. // Depth 0 is the project segment; not a canonical name.
if len(parts) >= 2 { if len(parts) >= 2 {
// Depth 1 candidate: archive (only physical project-root canonical). // Depth 1 candidate: any top-level peer (all physical now).
seg := strings.ToLower(parts[1]) seg := strings.ToLower(parts[1])
if seg == "archive" { if IsProjectPeer(seg) {
if err := resolveAt(1, seg); err != nil { if err := resolveAt(1, seg); err != nil {
return target, err return target, err
} }
} }
} }
// Depth 3 candidate (archive/<party>/<canonical-party>): the eight // Depth 3 candidate (archive/<party>/<slot>): the WORM record slots
// physical per-party slots. Only meaningful when depth 1 is // received/issued. Only meaningful when depth 1 is "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])
if IsPerPartySlot(seg) { if IsPerPartySlot(seg) {

View file

@ -263,6 +263,17 @@ type ZddcFile struct {
// not its descendants. Defaults (nil): no drop zone. // not its descendants. Defaults (nil): no drop zone.
DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"` DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"`
// PartySource names the registry that gates party-folder creation
// under THIS peer. When set (e.g. "ssr"), a new <party> segment may
// be created here only if the party is registered — i.e. the registry
// entry <project>/<party_source>/<party>.yaml (or .../<party>/) exists
// — otherwise the server 409s. The authoritative party registry is
// ssr/ (a party exists iff ssr/<party>.yaml exists); ssr/ itself sets
// no party_source. Leaf-only — the property describes THIS peer dir,
// checked via PartyRegistered at party-folder-creating entrypoints.
// Empty: no party gating.
PartySource string `yaml:"party_source,omitempty" json:"party_source,omitempty"`
// History enables server-managed edit-history versioning for text // History enables server-managed edit-history versioning for text
// (markdown) writes in this subtree. When true, each save of a // (markdown) writes in this subtree. When true, each save of a
// history-eligible file (see handler.IsTextHistoryCandidate) snapshots // history-eligible file (see handler.IsTextHistoryCandidate) snapshots

View file

@ -1,6 +1,7 @@
package zddc package zddc
import ( import (
"os"
"path/filepath" "path/filepath"
"strings" "strings"
) )
@ -128,6 +129,45 @@ func DropTargetAt(fsRoot, dirPath string) bool {
return false return false
} }
// PartySourceAt returns the registry name that gates party-folder
// creation under THIS peer directory (e.g. "ssr"), or "" if this peer
// does no party gating. Leaf-only — the property describes the peer dir
// (working/, archive/, …), not its party-folder children.
func PartySourceAt(fsRoot, dirPath string) string {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return ""
}
leaf := leafLevel(chain)
if leaf.PartySource != "" {
return leaf.PartySource
}
return chain.Embedded.PartySource
}
// PartyRegistered reports whether party is registered in the named
// registry under projectAbs (e.g. source="ssr" → the registry is
// <projectAbs>/ssr/). A party exists iff its registry entry exists,
// checked as either a flat row file <registry>/<party>.yaml or a folder
// <registry>/<party>/ (so the key works for flat-file and folder-style
// registers). An empty source means "no gating" and returns true.
func PartyRegistered(projectAbs, source, party string) bool {
if source == "" {
return true
}
if party == "" {
return false
}
reg := filepath.Join(projectAbs, source)
if fi, err := os.Stat(filepath.Join(reg, party+".yaml")); err == nil && fi.Mode().IsRegular() {
return true
}
if fi, err := os.Stat(filepath.Join(reg, party)); err == nil && fi.IsDir() {
return true
}
return false
}
// VirtualAt reports whether THIS specific directory is declared as // VirtualAt reports whether THIS specific directory is declared as
// purely virtual (never materialise on disk). Leaf-only: the virtual // purely virtual (never materialise on disk). Leaf-only: the virtual
// property describes a particular path, not a subtree. A child of a // property describes a particular path, not a subtree. A child of a
@ -264,36 +304,37 @@ func ChildrenDeclaredAt(fsRoot, dirPath string) []string {
} }
// CanonicalFolderAt returns the canonical-folder name for THIS specific // CanonicalFolderAt returns the canonical-folder name for THIS specific
// directory — one of "archive", "working", "staging", "reviewing", // directory — one of the top-level peers (archive, incoming, working,
// "incoming", "received", "issued", "mdl", "rsk" — or "" if the path // staging, reviewing, mdl, rsk, ssr) or the WORM record slots
// is not at a canonical-folder slot. // (received, issued) — or "" if the path is not at a canonical slot.
// //
// Detection is structural against the canonical project layout declared // Detection is structural against the flat-peer layout declared in
// in defaults.zddc.yaml: // defaults.zddc.yaml:
// //
// - top-level <project>/archive is the only physical project-root // - second-level <project>/<peer> for any top-level peer.
// canonical slot (the working/staging/reviewing/ssr/mdl/rsk URLs // - third-level <project>/<peer>/<party> reports its peer (slot) for
// at project root are virtual aggregators, not on-disk folders). // the workspace/register peers (not archive), so the SPA can scope
// - third-level archive/<party>/{mdl,rsk,incoming,received,issued, // party-level context-menu actions.
// working,staging,reviewing} are the physical per-party canonical // - fourth-level <project>/archive/<party>/{received,issued} are the
// slots. // WORM record slots.
// //
// Operators don't rename these slots (the cascade keys them by // Operators don't rename these slots (the cascade keys them by literal
// literal name); a custom layout that does is on its own. // name). Used by the browse SPA to scope-gate context-menu actions
// // (Accept, Stage/Unstage, Create Transmittal) without re-implementing
// Used by the browse SPA to scope-gate context-menu actions (Accept, // the cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder
// Stage/Unstage, Create Transmittal folder) without re-implementing the // header.
// cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder header.
func CanonicalFolderAt(fsRoot, dirPath string) string { func CanonicalFolderAt(fsRoot, dirPath string) string {
segs := resolvePathSegments(fsRoot, dirPath) segs := resolvePathSegments(fsRoot, dirPath)
// <project>/<folder> — only archive/ is physical at project root. // <project>/<peer> — all top-level peers are physical canonical slots.
if len(segs) == 2 { if len(segs) == 2 && IsProjectPeer(segs[1]) {
if segs[1] == "archive" { return segs[1]
return "archive"
} }
return "" // <project>/<peer>/<party> — the party folder under a workspace/
// register peer reports its peer; archive's party folder is not a slot.
if len(segs) == 3 && IsProjectPeer(segs[1]) && segs[1] != "archive" {
return segs[1]
} }
// <project>/archive/<party>/<folder> // <project>/archive/<party>/<slot> — the WORM record slots.
if len(segs) == 4 && segs[1] == "archive" && IsPerPartySlot(segs[3]) { if len(segs) == 4 && segs[1] == "archive" && IsPerPartySlot(segs[3]) {
return segs[3] return segs[3]
} }
@ -324,6 +365,9 @@ func isZeroZddcFile(zf ZddcFile) bool {
zf.DropTarget != nil || zf.Inherit != nil { zf.DropTarget != nil || zf.Inherit != nil {
return false return false
} }
if zf.PartySource != "" {
return false
}
if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" || if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" ||
zf.PlannedResponseDate != "" { zf.PlannedResponseDate != "" {
return false return false

View file

@ -2,29 +2,34 @@ package zddc
import "strings" import "strings"
// Canonical project slots — the fixed lifecycle shape of a project. // Canonical project shape — the fixed set of top-level peers and the
// per-party record slots under archive/.
// //
// The binary wires bespoke behavior to each of these names (transmittal at // The binary wires bespoke behavior to these names (tables at
// staging/, plan-review at received/, tables rollups at mdl/rsk, folder-nav // mdl/rsk/ssr, transmittal at staging/, WORM at archive/, the party
// at working/staging/reviewing), so the SET of slot names is a deliberate // registry at ssr/), so the SET of names is a deliberate hard rule
// hard rule rather than a cascade key. The point of this file is that the // rather than a cascade key. The point of this file is that the set
// set lives in ONE place: handlers ask the predicates below instead of // 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 // re-listing the names, so adding or adjusting a peer is a single edit.
// a hunt across ensure.go / fileapi.go / virtualviews.go / lookups.go.
// //
// Note the layering: the slot NAMES are hard-coded here, but per-slot // Note the layering: the names are hard-coded here, but per-peer
// BEHAVIOR (default_tool, history, worm, auto_own, virtual, …) stays // BEHAVIOR (default_tool, worm, party_source, history, auto_own, …)
// cascade-driven in defaults.zddc.yaml + on-disk .zddc. This file is // stays cascade-driven in defaults.zddc.yaml + on-disk .zddc. This file
// identity/shape only. // is identity/shape only.
var ( var (
// rowSlots: project-level tables rollups (ssr) + the per-party record // projectPeers: the flat set of physical directories permitted
// folders they aggregate (mdl, rsk). // directly under a project root. archive/ is the committed record;
// the rest are party-partitioned workspaces/registers. Mkdir at the
// project root is restricted to these plus system (_/.-prefixed)
// names.
projectPeers = []string{"archive", "incoming", "working", "staging", "reviewing", "mdl", "rsk", "ssr"}
// rowSlots: the tables-rendered register peers. mdl/rsk aggregate
// across their party subdirs; ssr is flat (one row file per party)
// and is also the authoritative party registry.
rowSlots = []string{"ssr", "mdl", "rsk"} rowSlots = []string{"ssr", "mdl", "rsk"}
// folderNavSlots: project-level folder-nav aggregators. // perPartySlots: the canonical lifecycle folders under
folderNavSlots = []string{"working", "staging", "reviewing"} // archive/<party>/ — the committed record, both WORM.
// perPartySlots: the physical lifecycle folders under archive/<party>/. perPartySlots = []string{"received", "issued"}
// (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 { func slotIn(set []string, s string) bool {
@ -36,26 +41,14 @@ func slotIn(set []string, s string) bool {
return false return false
} }
// IsRowSlot reports whether slot is a tables-rollup slot (ssr/mdl/rsk). // IsProjectPeer reports whether name is one of the fixed top-level peers
// permitted directly under a project root.
func IsProjectPeer(name string) bool { return slotIn(projectPeers, strings.ToLower(name)) }
// IsRowSlot reports whether slot is a tables-rendered register peer
// (ssr/mdl/rsk).
func IsRowSlot(slot string) bool { return slotIn(rowSlots, slot) } func IsRowSlot(slot string) bool { return slotIn(rowSlots, slot) }
// IsFolderNavSlot reports whether slot is a folder-nav lifecycle slot // IsPerPartySlot reports whether slot is a canonical lifecycle folder
// (working/staging/reviewing). // under archive/<party>/ (received/issued).
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) } 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

@ -9,336 +9,54 @@ import (
"strings" "strings"
) )
// Virtual project-level views. // Project-level helpers for the physical top-level peer layout.
// //
// Six aggregators live at <project>/, all sibling to the only real // There is no virtual URL space: every record row is addressed at its
// top-level directory archive/. None of them materialise on disk; the // real path (mdl/<party>/<file>.yaml, ssr/<party>.yaml). mdl/ and rsk/
// server synthesises listings by walking archive/*/ at request time // AGGREGATE across their party subdirs — the peer root renders one
// and (for the tables rollups) rewrites file reads/writes back to // table of every party's rows (a $party column derived from the real
// canonical paths inside the per-party folders. // subdir name), while <peer>/<party>/ shows that party's rows flat.
// // ssr/ aggregates naturally (one flat ssr/<party>.yaml per party) and
// Two aggregation shapes: // is the authoritative party registry. These helpers back the
// // aggregation listing and party-name validation.
// Row rollups (tables tool):
// <project>/ssr one row per party folder under archive/, backed
// by archive/<party>/ssr.yaml; synthesised key
// `name: <party>` is the identity column.
// <project>/mdl one row per *.yaml under archive/<party>/mdl/;
// synthesised key `$party: <party>` is the
// read-only source-party column. ($-prefix
// prevents collision with user-defined fields.)
// <project>/rsk same as mdl but for archive/<party>/rsk/.
//
// Folder-nav (browse tool):
// <project>/working list of archive/<party>/working/ that have
// non-empty content (in-flight filter). Per-
// party click 302s to the canonical path.
// <project>/staging same shape over archive/<party>/staging/.
// <project>/reviewing same shape over archive/<party>/reviewing/.
//
// ACL on each synthetic row/folder is evaluated against the canonical
// archive/<party>/ chain, so party owners can edit their own data and
// non-owners see them read-only.
//
// URL conventions
//
// /<project>/ssr/ → directory listing
// /<project>/ssr/table.yaml | form.yaml → embedded default spec
// /<project>/ssr/<party>.yaml → reads <project>/archive/<party>/ssr.yaml
// /<project>/ssr/<party>.yaml.html → form edit (form recognizer)
// /<project>/ssr/form.html → "+ Add row" — SSR create
//
// /<project>/mdl/ → rollup listing
// /<project>/mdl/table.yaml | form.yaml → embedded default project-rollup spec
// /<project>/mdl/<party>__<file>.yaml → reads <project>/archive/<party>/mdl/<file>.yaml
// /<project>/mdl/<party>__<file>.yaml.html → form edit
//
// /<project>/rsk/ → analogous
//
// /<project>/working/ → folder-nav listing (parties with content)
// /<project>/working/<party>/[<rest>] → 302 to /<project>/archive/<party>/working/<rest>
// /<project>/staging/, /<project>/reviewing/ → analogous folder-nav
//
// Modeled on virtualreceived.go: one resolver produces canonical
// paths; every caller (listing builder, file API rewrite, form
// recognizer) reads its policy chain from the canonical path.
// VirtualViewKind classifies a resolved virtual URL. // partyNameRE matches a valid party folder / registry-row token —
type VirtualViewKind int // starts with [A-Za-z0-9], then any of [A-Za-z0-9.-].
const (
VirtualViewNone VirtualViewKind = iota
VirtualViewSSRRoot
VirtualViewSSRSpec
VirtualViewSSRRow
VirtualViewMDLRoot
VirtualViewMDLSpec
VirtualViewMDLRow
VirtualViewRSKRoot
VirtualViewRSKSpec
VirtualViewRSKRow
// Folder-nav: top-level listing of parties with non-empty
// content in the named lifecycle slot.
VirtualViewFolderNavRoot
// Folder-nav: a per-party URL under one of the folder-nav
// roots. Resolves to a 302 redirect at canonical
// /<project>/archive/<party>/<slot>/<rest>.
VirtualViewFolderNavRedir
)
// IsRowKind reports whether k targets a per-party row file (true for
// SSRRow, MDLRow, RSKRow).
func (k VirtualViewKind) IsRowKind() bool {
switch k {
case VirtualViewSSRRow, VirtualViewMDLRow, VirtualViewRSKRow:
return true
}
return false
}
// IsSpecKind reports whether k targets a virtual table.yaml/form.yaml.
func (k VirtualViewKind) IsSpecKind() bool {
switch k {
case VirtualViewSSRSpec, VirtualViewMDLSpec, VirtualViewRSKSpec:
return true
}
return false
}
// IsRootKind reports whether k targets the listing-level URL of a
// virtual view.
func (k VirtualViewKind) IsRootKind() bool {
switch k {
case VirtualViewSSRRoot, VirtualViewMDLRoot, VirtualViewRSKRoot,
VirtualViewFolderNavRoot:
return true
}
return false
}
// IsFolderNavKind reports whether k is one of the folder-nav virtuals
// (working, staging, reviewing). Folder-nav views surface a per-party
// listing at the root and 302 redirect at every per-party URL.
func (k VirtualViewKind) IsFolderNavKind() bool {
switch k {
case VirtualViewFolderNavRoot, VirtualViewFolderNavRedir:
return true
}
return false
}
// VirtualViewResolution captures the result of mapping a URL onto
// one of the project-level virtual views. All fields are populated
// only when Resolved is true.
type VirtualViewResolution struct {
Resolved bool
Kind VirtualViewKind
Project string // "<project>"
ProjectURL string // "/<project>/"
ProjectAbs string // <fsRoot>/<project>
Slot string // "ssr", "mdl", "rsk", "working", "staging", "reviewing"
SlotURL string // "/<project>/<slot>/"
// Populated for VirtualView*Spec kinds: "table.yaml" or "form.yaml".
SpecBase string
// Populated for VirtualView*Row kinds.
Party string // party folder name (e.g. "0330C1")
PartyArchive string // <fsRoot>/<project>/archive/<party>
CanonicalAbs string // underlying file on disk
CanonicalURL string // /<project>/archive/<party>/...
SchemaAbs string // SSR only — <party>/ssr.form.yaml (may not exist; falls back to embedded)
RowFilename string // MDL/RSK rollups only — e.g. "D-001.yaml"
// Populated for VirtualViewFolderNavRedir. The path component
// AFTER the party — empty for /<project>/<slot>/<party>/ itself,
// or the URL-decoded sub-path for deeper URLs. The redirect
// target is /<project>/archive/<party>/<slot>/<RedirRest>.
RedirRest string
}
// virtualViewRE matches /<project>/<slot>[/<rest>] 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(`^/([^/]+)/(` + virtualAggregatorAlternation() + `)(?:/(.*))?$`)
// partyNameRE matches the SSR schema's `name` pattern. Same regex
// used at row-resolution time so URLs with invalid party tokens fail
// resolution cleanly instead of producing impossible canonical paths.
var partyNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]*$`) var partyNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.-]*$`)
// ValidPartyName reports whether s is a valid party folder name — // ValidPartyName reports whether s is a valid party name. Used by URL
// starts with [A-Za-z0-9], then any of [A-Za-z0-9.-]. Used by URL
// resolution AND by the SSR create handler to validate user input. // resolution AND by the SSR create handler to validate user input.
func ValidPartyName(s string) bool { func ValidPartyName(s string) bool {
return partyNameRE.MatchString(s) return partyNameRE.MatchString(s)
} }
// IsFolderNavSlot / IsRowSlot / IsVirtualAggregatorSlot / IsPerPartySlot
// live in slots.go (the single canonical-slot registry).
// 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.
var planReviewURLRE = regexp.MustCompile(`^/[^/]+/archive/[^/]+/received/[^/]+/?$`) var planReviewURLRE = regexp.MustCompile(`^/[^/]+/archive/[^/]+/received/[^/]+/?$`)
// IsPlanReviewURL reports whether urlPath is a directory URL eligible // IsPlanReviewURL reports whether urlPath is a directory URL eligible
// for the Plan Review composite endpoint — i.e. it points at the // for the Plan Review composite endpoint — i.e. it points at the
// canonical received/<tracking>/ folder under archive/<party>/. Used // canonical received/<tracking>/ folder under archive/<party>/.
// to surface X-ZDDC-On-Plan-Review on directory responses so the // Eligibility is purely structural; the handler-side authorisation
// browse client can show/hide the right-click menu item. // check still gates the actual operation.
//
// Eligibility is purely structural — no cascade lookup, no per-
// project configuration. The handler-side authorisation check still
// gates the actual operation.
func IsPlanReviewURL(urlPath string) bool { func IsPlanReviewURL(urlPath string) bool {
return planReviewURLRE.MatchString(urlPath) return planReviewURLRE.MatchString(urlPath)
} }
// ResolveVirtualView inspects urlPath and returns a populated // StripYAMLHTML returns urlPath with a trailing ".html" stripped iff
// resolution iff the URL targets one of the project-level virtual // the URL has the form-edit shape ".../<name>.yaml.html". Otherwise
// views (ssr/, mdl/, rsk/, working/, staging/, reviewing/). // returns urlPath unchanged + false. The form recognizer calls this
// Resolved=false on non-match. // to map a form-edit URL onto the underlying data file.
// func StripYAMLHTML(urlPath string) (string, bool) {
// The resolver does NOT check that the project / party / row file if strings.HasSuffix(urlPath, ".yaml.html") {
// actually exist on disk — that's the caller's job (handlers use return strings.TrimSuffix(urlPath, ".html"), true
// the canonical path; listings synthesize from real disk state).
//
// urlPath must be a server-relative URL with one leading slash.
// Trailing slashes are tolerated for root kinds.
func ResolveVirtualView(fsRoot, urlPath string) VirtualViewResolution {
var out VirtualViewResolution
if urlPath == "" || urlPath[0] != '/' {
return out
} }
trimmed := strings.TrimSuffix(urlPath, "/") return urlPath, false
m := virtualViewRE.FindStringSubmatch(trimmed)
if m == nil {
return out
}
project := m[1]
slot := m[2]
rest := m[3]
if project == "" || strings.HasPrefix(project, ".") || strings.HasPrefix(project, "_") {
return out
}
projectAbs := filepath.Join(fsRoot, filepath.FromSlash(project))
if !strings.HasPrefix(projectAbs, fsRoot+string(filepath.Separator)) && projectAbs != fsRoot {
return out
}
out.Project = project
out.ProjectURL = "/" + project + "/"
out.ProjectAbs = projectAbs
out.Slot = slot
out.SlotURL = "/" + project + "/" + slot + "/"
if rest == "" {
if IsFolderNavSlot(slot) {
out.Kind = VirtualViewFolderNavRoot
} else {
switch slot {
case "ssr":
out.Kind = VirtualViewSSRRoot
case "mdl":
out.Kind = VirtualViewMDLRoot
case "rsk":
out.Kind = VirtualViewRSKRoot
}
}
out.Resolved = true
return out
}
// Folder-nav slots: any non-empty rest is a per-party redirect
// target. /<project>/working/<party>[/...] → 302 to canonical
// /<project>/archive/<party>/working[/...].
if IsFolderNavSlot(slot) {
// Split off the party (first segment) from the rest.
party := rest
var redirRest string
if idx := strings.Index(rest, "/"); idx >= 0 {
party = rest[:idx]
redirRest = rest[idx+1:]
}
if !ValidPartyName(party) {
return out
}
out.Party = party
out.PartyArchive = filepath.Join(projectAbs, "archive", party)
out.RedirRest = redirRest
out.CanonicalURL = "/" + project + "/archive/" + party + "/" + slot + "/"
if redirRest != "" {
out.CanonicalURL += redirRest
}
out.Kind = VirtualViewFolderNavRedir
out.Resolved = true
return out
}
if rest == "table.yaml" || rest == "form.yaml" {
switch slot {
case "ssr":
out.Kind = VirtualViewSSRSpec
case "mdl":
out.Kind = VirtualViewMDLSpec
case "rsk":
out.Kind = VirtualViewRSKSpec
}
out.SpecBase = rest
out.Resolved = true
return out
}
// Row files — must be a single segment ending in .yaml.
if strings.Contains(rest, "/") || !strings.HasSuffix(rest, ".yaml") {
return out
}
name := strings.TrimSuffix(rest, ".yaml")
if slot == "ssr" {
if !ValidPartyName(name) {
return out
}
out.Party = name
out.PartyArchive = filepath.Join(projectAbs, "archive", name)
out.CanonicalAbs = filepath.Join(out.PartyArchive, "ssr.yaml")
out.CanonicalURL = "/" + project + "/archive/" + name + "/ssr.yaml"
out.SchemaAbs = filepath.Join(out.PartyArchive, "ssr.form.yaml")
out.Kind = VirtualViewSSRRow
out.Resolved = true
return out
}
// MDL/RSK rollup row — <party>__<file>.yaml.
idx := strings.Index(name, "__")
if idx <= 0 || idx >= len(name)-2 {
return out
}
party := name[:idx]
rowBase := name[idx+2:]
if !ValidPartyName(party) || rowBase == "" || strings.Contains(rowBase, "__") {
return out
}
out.Party = party
out.PartyArchive = filepath.Join(projectAbs, "archive", party)
out.RowFilename = rowBase + ".yaml"
out.CanonicalAbs = filepath.Join(out.PartyArchive, slot, out.RowFilename)
out.CanonicalURL = "/" + project + "/archive/" + party + "/" + slot + "/" + out.RowFilename
switch slot {
case "mdl":
out.Kind = VirtualViewMDLRow
case "rsk":
out.Kind = VirtualViewRSKRow
}
out.Resolved = true
return out
} }
// IsSSRCreateURL reports whether urlPath is /<project>/ssr/form.html // IsSSRCreateURL reports whether urlPath is /<project>/ssr/form.html —
// — the SSR "+ Add row" target. Returns the project name when matched. // the SSR "+ Register party" target. Returns the project name when
// matched. The handler writes the new ssr/<party>.yaml registry row.
func IsSSRCreateURL(urlPath string) (string, bool) { func IsSSRCreateURL(urlPath string) (string, bool) {
if urlPath == "" || urlPath[0] != '/' { if urlPath == "" || urlPath[0] != '/' {
return "", false return "", false
@ -356,11 +74,10 @@ func IsSSRCreateURL(urlPath string) (string, bool) {
// IsRollupCreateURL reports whether urlPath is // IsRollupCreateURL reports whether urlPath is
// /<project>/(mdl|rsk)/form.html — the "+ Add row" target on a // /<project>/(mdl|rsk)/form.html — the "+ Add row" target on a
// project-level MDL or RSK rollup view. Returns the project name + // project-level MDL or RSK aggregate view. Returns the project name +
// slot ("mdl" or "rsk") when matched. The rollup-create handler // peer ("mdl" or "rsk") when matched. The handler reads a `party` field
// reads a `party` field from the body and routes the new row into // from the body and routes the new row into <project>/<peer>/<party>/.
// <project>/archive/<party>/<slot>/. func IsRollupCreateURL(urlPath string) (project, peer string, ok bool) {
func IsRollupCreateURL(urlPath string) (project, slot string, ok bool) {
if urlPath == "" || urlPath[0] != '/' { if urlPath == "" || urlPath[0] != '/' {
return "", "", false return "", "", false
} }
@ -378,25 +95,13 @@ func IsRollupCreateURL(urlPath string) (project, slot string, ok bool) {
return project, parts[1], true return project, parts[1], true
} }
// StripYAMLHTML returns urlPath with a trailing ".html" stripped iff // ListParties returns the registered party names under <project>/ssr/
// the URL has the form-edit shape ".../<name>.yaml.html". Otherwise // — one per ssr/<party>.yaml file (the authoritative party registry).
// returns urlPath unchanged + false. The form recognizer calls this // Names are filtered through ValidPartyName. Returns nil + nil when
// before passing the data URL into ResolveVirtualView. // ssr/ doesn't exist on disk.
func StripYAMLHTML(urlPath string) (string, bool) { func ListParties(projectAbs string) ([]string, error) {
if strings.HasSuffix(urlPath, ".yaml.html") { reg := filepath.Join(projectAbs, "ssr")
return strings.TrimSuffix(urlPath, ".html"), true entries, err := os.ReadDir(reg)
}
return urlPath, false
}
// ListSSRParties returns the party folder names that exist under
// <project>/archive/. Names are filtered through ValidPartyName so a
// hand-created folder with a weird name (e.g. "0330C1 (draft)") won't
// confuse the rest of the resolver. Returns nil + nil when archive/
// doesn't exist on disk.
func ListSSRParties(fsRoot, projectAbs string) ([]string, error) {
archive := filepath.Join(projectAbs, "archive")
entries, err := os.ReadDir(archive)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return nil, nil return nil, nil
@ -405,10 +110,10 @@ func ListSSRParties(fsRoot, projectAbs string) ([]string, error) {
} }
out := make([]string, 0, len(entries)) out := make([]string, 0, len(entries))
for _, e := range entries { for _, e := range entries {
if !e.IsDir() { if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue continue
} }
name := e.Name() name := strings.TrimSuffix(e.Name(), ".yaml")
if !ValidPartyName(name) { if !ValidPartyName(name) {
continue continue
} }
@ -418,61 +123,59 @@ func ListSSRParties(fsRoot, projectAbs string) ([]string, error) {
return out, nil return out, nil
} }
// VirtualRollupRow describes one synthetic row in a project-level // RollupRow describes one row in an mdl/ or rsk/ aggregate table,
// MDL or RSK rollup. // gathered from the physical <project>/<peer>/<party>/<file>.yaml.
type VirtualRollupRow struct { type RollupRow struct {
Party string // source party folder Party string // source party (real subdir name → the $party column)
Filename string // e.g. "D-001.yaml" Filename string // e.g. "ACME-PRJ-EL-SPC-0001.yaml"
SyntheticName string // e.g. "0330C1__D-001.yaml" — used in URLs Abs string // underlying file on disk
CanonicalAbs string // underlying file on disk RelURL string // /<project>/<peer>/<party>/<file>.yaml
} }
// ListRollupRows walks <project>/archive/*/<slot>/ and returns one // ListRollupRows walks <project>/<peer>/*/*.yaml (peer = mdl|rsk) and
// synthetic row per *.yaml file. slot must be "mdl" or "rsk". // returns one row per *.yaml file, sorted by (party, filename). The
// Returns rows sorted by (party, filename). // $party column is the real subdir name. Skipped: table.yaml / form.yaml
// // specs, non-*.yaml files, and party dirs with invalid names. Returns
// Skipped: // nil + nil when the peer dir doesn't exist on disk.
// - filenames containing "__" (would break the party__file split) func ListRollupRows(projectAbs, peer string) ([]RollupRow, error) {
// - "table.yaml" and "form.yaml" (operator spec/schema, not rows) if peer != "mdl" && peer != "rsk" {
// - any non-*.yaml file return nil, errors.New("ListRollupRows: peer must be mdl or rsk")
// - parties with invalid folder names (filtered by ListSSRParties)
func ListRollupRows(fsRoot, projectAbs, slot string) ([]VirtualRollupRow, error) {
if slot != "mdl" && slot != "rsk" {
return nil, errors.New("ListRollupRows: slot must be mdl or rsk")
} }
parties, err := ListSSRParties(fsRoot, projectAbs) peerAbs := filepath.Join(projectAbs, peer)
if err != nil { partyEntries, err := os.ReadDir(peerAbs)
return nil, err
}
out := make([]VirtualRollupRow, 0, len(parties))
for _, party := range parties {
slotDir := filepath.Join(projectAbs, "archive", party, slot)
entries, err := os.ReadDir(slotDir)
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
continue return nil, nil
} }
return nil, err return nil, err
} }
for _, e := range entries { project := filepath.Base(projectAbs)
if e.IsDir() { var out []RollupRow
for _, pe := range partyEntries {
if !pe.IsDir() {
continue continue
} }
name := e.Name() party := pe.Name()
if !strings.HasSuffix(name, ".yaml") { if !ValidPartyName(party) {
continue continue
} }
if name == "table.yaml" || name == "form.yaml" { partyDir := filepath.Join(peerAbs, party)
rows, err := os.ReadDir(partyDir)
if err != nil {
return nil, err
}
for _, e := range rows {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue continue
} }
if strings.Contains(name, "__") { if e.Name() == "table.yaml" || e.Name() == "form.yaml" {
continue continue
} }
out = append(out, VirtualRollupRow{ out = append(out, RollupRow{
Party: party, Party: party,
Filename: name, Filename: e.Name(),
SyntheticName: party + "__" + name, Abs: filepath.Join(partyDir, e.Name()),
CanonicalAbs: filepath.Join(slotDir, name), RelURL: "/" + project + "/" + peer + "/" + party + "/" + e.Name(),
}) })
} }
} }
@ -484,52 +187,3 @@ func ListRollupRows(fsRoot, projectAbs, slot string) ([]VirtualRollupRow, error)
}) })
return out, nil return out, nil
} }
// ListPartyDirsInSlot walks <project>/archive/*/<slot>/ and returns
// the party folder names whose slot directory exists AND has
// non-empty content (the "in-flight" filter). slot must be one of
// "working", "staging", "reviewing". Returns nil + nil when archive/
// doesn't exist on disk.
//
// Used by the folder-nav virtuals at <project>/<slot>/ to list only
// parties that have something to show. Parties whose archive/<party>/
// <slot>/ is absent or contains only system files (.zddc) are
// suppressed from the listing.
func ListPartyDirsInSlot(fsRoot, projectAbs, slot string) ([]string, error) {
if !IsFolderNavSlot(slot) {
return nil, errors.New("ListPartyDirsInSlot: slot must be working/staging/reviewing")
}
parties, err := ListSSRParties(fsRoot, projectAbs)
if err != nil {
return nil, err
}
out := make([]string, 0, len(parties))
for _, party := range parties {
slotDir := filepath.Join(projectAbs, "archive", party, slot)
if !slotDirHasContent(slotDir) {
continue
}
out = append(out, party)
}
sort.Strings(out)
return out, nil
}
// slotDirHasContent reports whether slotDir is a directory with at
// least one entry that isn't a .-prefixed system file. Treats
// .zddc-only directories as empty so the folder-nav listing doesn't
// fire for parties whose lifecycle slot was scaffolded but never
// populated with real work.
func slotDirHasContent(slotDir string) bool {
entries, err := os.ReadDir(slotDir)
if err != nil {
return false
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") {
continue
}
return true
}
return false
}

View file

@ -7,264 +7,21 @@ import (
"testing" "testing"
) )
func TestResolveVirtualView_Roots(t *testing.T) { func TestValidPartyName(t *testing.T) {
root := t.TempDir() ok := []string{"0330C1", "Acme", "Acme.Inc", "Acme-Subsidiary", "a", "0", "0330C1.draft", "X-Y-Z"}
cases := []struct { bad := []string{"", ".hidden", "_underscore", "Acme/sub", "Acme Inc", "Acme(Inc)", "Acme,Inc", "..", "-leading-dash"}
url string for _, s := range ok {
want VirtualViewKind if !ValidPartyName(s) {
}{ t.Errorf("ValidPartyName(%q) = false, want true", s)
{"/Project/ssr", VirtualViewSSRRoot},
{"/Project/ssr/", VirtualViewSSRRoot},
{"/Project/mdl", VirtualViewMDLRoot},
{"/Project/mdl/", VirtualViewMDLRoot},
{"/Project/rsk", VirtualViewRSKRoot},
{"/Project/rsk/", VirtualViewRSKRoot},
} }
for _, tc := range cases {
got := ResolveVirtualView(root, tc.url)
if !got.Resolved || got.Kind != tc.want {
t.Errorf("%s: kind=%d resolved=%v; want kind=%d resolved=true", tc.url, got.Kind, got.Resolved, tc.want)
} }
if got.Project != "Project" { for _, s := range bad {
t.Errorf("%s: project=%q want Project", tc.url, got.Project) if ValidPartyName(s) {
} t.Errorf("ValidPartyName(%q) = true, want false", s)
if !got.Kind.IsRootKind() {
t.Errorf("%s: IsRootKind=false", tc.url)
} }
} }
} }
func TestResolveVirtualView_Specs(t *testing.T) {
root := t.TempDir()
cases := []struct {
url string
wantKind VirtualViewKind
wantBase string
}{
{"/Project/ssr/table.yaml", VirtualViewSSRSpec, "table.yaml"},
{"/Project/ssr/form.yaml", VirtualViewSSRSpec, "form.yaml"},
{"/Project/mdl/table.yaml", VirtualViewMDLSpec, "table.yaml"},
{"/Project/mdl/form.yaml", VirtualViewMDLSpec, "form.yaml"},
{"/Project/rsk/table.yaml", VirtualViewRSKSpec, "table.yaml"},
{"/Project/rsk/form.yaml", VirtualViewRSKSpec, "form.yaml"},
}
for _, tc := range cases {
got := ResolveVirtualView(root, tc.url)
if !got.Resolved || got.Kind != tc.wantKind {
t.Errorf("%s: kind=%d resolved=%v; want kind=%d", tc.url, got.Kind, got.Resolved, tc.wantKind)
}
if got.SpecBase != tc.wantBase {
t.Errorf("%s: SpecBase=%q want %q", tc.url, got.SpecBase, tc.wantBase)
}
if !got.Kind.IsSpecKind() {
t.Errorf("%s: IsSpecKind=false", tc.url)
}
}
}
func TestResolveVirtualView_SSRRow(t *testing.T) {
root := t.TempDir()
got := ResolveVirtualView(root, "/Project/ssr/0330C1.yaml")
if !got.Resolved || got.Kind != VirtualViewSSRRow {
t.Fatalf("unexpected resolution: %+v", got)
}
if got.Party != "0330C1" {
t.Errorf("Party=%q want 0330C1", got.Party)
}
wantAbs := filepath.Join(root, "Project", "archive", "0330C1", "ssr.yaml")
if got.CanonicalAbs != wantAbs {
t.Errorf("CanonicalAbs=%q want %q", got.CanonicalAbs, wantAbs)
}
wantSchema := filepath.Join(root, "Project", "archive", "0330C1", "ssr.form.yaml")
if got.SchemaAbs != wantSchema {
t.Errorf("SchemaAbs=%q want %q", got.SchemaAbs, wantSchema)
}
if !got.Kind.IsRowKind() {
t.Errorf("IsRowKind=false")
}
}
func TestResolveVirtualView_RollupRow(t *testing.T) {
root := t.TempDir()
cases := []struct {
url string
wantKind VirtualViewKind
wantParty string
wantFilename string
wantSlot string
}{
{"/Project/mdl/0330C1__D-001.yaml", VirtualViewMDLRow, "0330C1", "D-001.yaml", "mdl"},
{"/Project/rsk/Acme__R-005.yaml", VirtualViewRSKRow, "Acme", "R-005.yaml", "rsk"},
}
for _, tc := range cases {
got := ResolveVirtualView(root, tc.url)
if !got.Resolved || got.Kind != tc.wantKind {
t.Errorf("%s: kind=%d resolved=%v; want kind=%d", tc.url, got.Kind, got.Resolved, tc.wantKind)
continue
}
if got.Party != tc.wantParty {
t.Errorf("%s: Party=%q want %q", tc.url, got.Party, tc.wantParty)
}
if got.RowFilename != tc.wantFilename {
t.Errorf("%s: RowFilename=%q want %q", tc.url, got.RowFilename, tc.wantFilename)
}
wantAbs := filepath.Join(root, "Project", "archive", tc.wantParty, tc.wantSlot, tc.wantFilename)
if got.CanonicalAbs != wantAbs {
t.Errorf("%s: CanonicalAbs=%q want %q", tc.url, got.CanonicalAbs, wantAbs)
}
}
}
func TestResolveVirtualView_NonMatches(t *testing.T) {
root := t.TempDir()
cases := []string{
"/",
"/Project",
"/Project/",
"/Project/archive/Acme/mdl",
"/Project/ssr/invalid__name__double.yaml", // double-double underscore is rejected
"/Project/mdl/__leading.yaml", // empty party
"/Project/mdl/party__.yaml", // empty rowBase
"/Project/ssr/.hidden.yaml", // dotfile party name
"/Project/ssr/0330C1.yaml/sub", // sub-path under row file
"/Project/notaslot/table.yaml",
}
for _, url := range cases {
got := ResolveVirtualView(root, url)
if got.Resolved {
t.Errorf("%s: unexpectedly resolved as kind %d", url, got.Kind)
}
}
}
// TestResolveVirtualView_FolderNavRoot — the project-level virtual
// folder-nav aggregators resolve to VirtualViewFolderNavRoot for the
// bare slot URL (trailing slash optional).
func TestResolveVirtualView_FolderNavRoot(t *testing.T) {
root := t.TempDir()
cases := []struct {
url string
slot string
}{
{"/Project/working", "working"},
{"/Project/working/", "working"},
{"/Project/staging", "staging"},
{"/Project/staging/", "staging"},
{"/Project/reviewing", "reviewing"},
{"/Project/reviewing/", "reviewing"},
}
for _, tc := range cases {
got := ResolveVirtualView(root, tc.url)
if !got.Resolved || got.Kind != VirtualViewFolderNavRoot {
t.Errorf("%s: kind=%d resolved=%v; want FolderNavRoot resolved=true", tc.url, got.Kind, got.Resolved)
}
if got.Slot != tc.slot {
t.Errorf("%s: Slot=%q want %q", tc.url, got.Slot, tc.slot)
}
if !got.Kind.IsRootKind() {
t.Errorf("%s: IsRootKind=false", tc.url)
}
if !got.Kind.IsFolderNavKind() {
t.Errorf("%s: IsFolderNavKind=false", tc.url)
}
}
}
// TestResolveVirtualView_FolderNavRedir — URLs deeper than the bare
// slot resolve to VirtualViewFolderNavRedir with Party + RedirRest
// populated; the dispatcher 302s these to the canonical
// archive/<party>/<slot>/<rest> path.
func TestResolveVirtualView_FolderNavRedir(t *testing.T) {
root := t.TempDir()
cases := []struct {
url string
wantParty string
wantRedirRest string
wantCanonical string
}{
{"/Project/working/Acme", "Acme", "", "/Project/archive/Acme/working/"},
{"/Project/working/Acme/", "Acme", "", "/Project/archive/Acme/working/"},
{"/Project/staging/Acme/2026-05-15_X (RFI) - T", "Acme", "2026-05-15_X (RFI) - T", "/Project/archive/Acme/staging/2026-05-15_X (RFI) - T"},
// Trailing slash is stripped at resolver entry; the dispatcher
// re-appends it before issuing the 302 to match the request shape.
{"/Project/reviewing/Acme/T-0042/", "Acme", "T-0042", "/Project/archive/Acme/reviewing/T-0042"},
}
for _, tc := range cases {
got := ResolveVirtualView(root, tc.url)
if !got.Resolved || got.Kind != VirtualViewFolderNavRedir {
t.Errorf("%s: kind=%d resolved=%v; want FolderNavRedir resolved=true", tc.url, got.Kind, got.Resolved)
continue
}
if got.Party != tc.wantParty {
t.Errorf("%s: Party=%q want %q", tc.url, got.Party, tc.wantParty)
}
if got.RedirRest != tc.wantRedirRest {
t.Errorf("%s: RedirRest=%q want %q", tc.url, got.RedirRest, tc.wantRedirRest)
}
if got.CanonicalURL != tc.wantCanonical {
t.Errorf("%s: CanonicalURL=%q want %q", tc.url, got.CanonicalURL, tc.wantCanonical)
}
if !got.Kind.IsFolderNavKind() {
t.Errorf("%s: IsFolderNavKind=false", tc.url)
}
}
}
// TestListPartyDirsInSlot — folder-nav listings include only parties
// whose archive/<party>/<slot>/ directory exists AND has non-empty
// content (the in-flight filter). Parties with an empty or absent
// slot directory are suppressed.
func TestListPartyDirsInSlot(t *testing.T) {
root := t.TempDir()
projectAbs := filepath.Join(root, "Project")
// Acme has working content; Beta has only a .zddc system file
// (counts as empty); Gamma has the slot directory but it's
// completely empty; Delta doesn't have the slot at all.
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Acme", "working"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "Acme", "working", "draft.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Beta", "working"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "Beta", "working", ".zddc"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Gamma", "working"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", "Delta"), 0o755); err != nil {
t.Fatal(err)
}
got, err := ListPartyDirsInSlot(root, projectAbs, "working")
if err != nil {
t.Fatal(err)
}
want := []string{"Acme"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("ListPartyDirsInSlot(working) = %v, want %v", got, want)
}
}
// TestListPartyDirsInSlot_BadSlot — only the three folder-nav slots
// are valid.
func TestListPartyDirsInSlot_BadSlot(t *testing.T) {
root := t.TempDir()
for _, bad := range []string{"ssr", "mdl", "rsk", "received", "issued", "incoming", ""} {
if _, err := ListPartyDirsInSlot(root, root, bad); err == nil {
t.Errorf("expected error for slot=%q (only working/staging/reviewing valid)", bad)
}
}
}
// TestIsPlanReviewURL — the eligibility test surfaces the X-ZDDC-On-
// Plan-Review header. Matches /<project>/archive/<party>/received/
// <tracking>/ with or without trailing slash; everything else returns
// false.
func TestIsPlanReviewURL(t *testing.T) { func TestIsPlanReviewURL(t *testing.T) {
cases := []struct { cases := []struct {
url string url string
@ -277,8 +34,6 @@ func TestIsPlanReviewURL(t *testing.T) {
{"/Project/archive/Acme/received/Acme-0042/file.pdf", false}, {"/Project/archive/Acme/received/Acme-0042/file.pdf", false},
{"/Project/archive/Acme/issued/Acme-0042/", false}, {"/Project/archive/Acme/issued/Acme-0042/", false},
{"/Project/archive/Acme", false}, {"/Project/archive/Acme", false},
{"/Project/archive", false},
{"/Project", false},
{"/", false}, {"/", false},
{"", false}, {"", false},
} }
@ -304,11 +59,29 @@ func TestIsSSRCreateURL(t *testing.T) {
} }
for _, tc := range cases { for _, tc := range cases {
got, ok := IsSSRCreateURL(tc.url) got, ok := IsSSRCreateURL(tc.url)
if ok != tc.wantOK { if ok != tc.wantOK || got != tc.want {
t.Errorf("%s: ok=%v want %v", tc.url, ok, tc.wantOK) t.Errorf("IsSSRCreateURL(%q) = (%q,%v) want (%q,%v)", tc.url, got, ok, tc.want, tc.wantOK)
} }
if got != tc.want { }
t.Errorf("%s: project=%q want %q", tc.url, got, tc.want) }
func TestIsRollupCreateURL(t *testing.T) {
cases := []struct {
url string
wantProj string
wantPeer string
wantOK bool
}{
{"/Project/mdl/form.html", "Project", "mdl", true},
{"/Project/rsk/form.html", "Project", "rsk", true},
{"/Project/ssr/form.html", "", "", false},
{"/Project/mdl/Acme/form.html", "", "", false},
{"/.hidden/mdl/form.html", "", "", false},
}
for _, tc := range cases {
proj, peer, ok := IsRollupCreateURL(tc.url)
if ok != tc.wantOK || proj != tc.wantProj || peer != tc.wantPeer {
t.Errorf("IsRollupCreateURL(%q) = (%q,%q,%v) want (%q,%q,%v)", tc.url, proj, peer, ok, tc.wantProj, tc.wantPeer, tc.wantOK)
} }
} }
} }
@ -319,8 +92,8 @@ func TestStripYAMLHTML(t *testing.T) {
want string want string
wantOK bool wantOK bool
}{ }{
{"/Project/mdl/Acme/D-001.yaml.html", "/Project/mdl/Acme/D-001.yaml", true},
{"/Project/ssr/Acme.yaml.html", "/Project/ssr/Acme.yaml", true}, {"/Project/ssr/Acme.yaml.html", "/Project/ssr/Acme.yaml", true},
{"/Project/mdl/foo__bar.yaml.html", "/Project/mdl/foo__bar.yaml", true},
{"/Project/ssr/Acme.yaml", "/Project/ssr/Acme.yaml", false}, {"/Project/ssr/Acme.yaml", "/Project/ssr/Acme.yaml", false},
{"/Project/ssr/form.html", "/Project/ssr/form.html", false}, {"/Project/ssr/form.html", "/Project/ssr/form.html", false},
} }
@ -332,51 +105,34 @@ func TestStripYAMLHTML(t *testing.T) {
} }
} }
func TestValidPartyName(t *testing.T) { // ListParties reads the registry — one ssr/<party>.yaml per registered party.
ok := []string{"0330C1", "Acme", "Acme.Inc", "Acme-Subsidiary", "a", "0", "0330C1.draft", "X-Y-Z"} func TestListParties(t *testing.T) {
bad := []string{"", ".hidden", "_underscore", "Acme/sub", "Acme Inc", "Acme(Inc)", "Acme,Inc", "..", "-leading-dash"}
for _, s := range ok {
if !ValidPartyName(s) {
t.Errorf("ValidPartyName(%q) = false, want true", s)
}
}
for _, s := range bad {
if ValidPartyName(s) {
t.Errorf("ValidPartyName(%q) = true, want false", s)
}
}
}
func TestListSSRParties(t *testing.T) {
root := t.TempDir() root := t.TempDir()
projectAbs := filepath.Join(root, "Project") projectAbs := filepath.Join(root, "Project")
ssrDir := filepath.Join(projectAbs, "ssr")
if err := os.MkdirAll(ssrDir, 0o755); err != nil {
t.Fatal(err)
}
for _, party := range []string{"0330C1", "0440P2", "Acme"} { for _, party := range []string{"0330C1", "0440P2", "Acme"} {
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", party), 0o755); err != nil { if err := os.WriteFile(filepath.Join(ssrDir, party+".yaml"), []byte("kind: SSR\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
// A file (not a dir) and a hidden folder should be filtered out. // Non-.yaml files filtered out.
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "stray.txt"), []byte("x"), 0o644); err != nil { _ = os.WriteFile(filepath.Join(ssrDir, "stray.txt"), []byte("x"), 0o644)
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(projectAbs, "archive", ".hidden"), 0o755); err != nil {
t.Fatal(err)
}
parties, err := ListSSRParties(root, projectAbs) parties, err := ListParties(projectAbs)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
want := []string{"0330C1", "0440P2", "Acme"} if strings.Join(parties, ",") != "0330C1,0440P2,Acme" {
if strings.Join(parties, ",") != strings.Join(want, ",") { t.Errorf("ListParties = %v, want [0330C1 0440P2 Acme]", parties)
t.Errorf("got %v, want %v", parties, want)
} }
} }
func TestListSSRParties_NoArchive(t *testing.T) { func TestListParties_NoRegistry(t *testing.T) {
root := t.TempDir() root := t.TempDir()
projectAbs := filepath.Join(root, "Project") parties, err := ListParties(filepath.Join(root, "Project"))
parties, err := ListSSRParties(root, projectAbs)
if err != nil { if err != nil {
t.Fatalf("err=%v want nil", err) t.Fatalf("err=%v want nil", err)
} }
@ -385,55 +141,51 @@ func TestListSSRParties_NoArchive(t *testing.T) {
} }
} }
// ListRollupRows aggregates physical <project>/<peer>/<party>/*.yaml.
func TestListRollupRows(t *testing.T) { func TestListRollupRows(t *testing.T) {
root := t.TempDir() root := t.TempDir()
projectAbs := filepath.Join(root, "Project") projectAbs := filepath.Join(root, "Project")
for _, party := range []string{"0330C1", "0440P2"} { for _, party := range []string{"0330C1", "0440P2"} {
mdlDir := filepath.Join(projectAbs, "archive", party, "mdl") if err := os.MkdirAll(filepath.Join(projectAbs, "mdl", party), 0o755); err != nil {
if err := os.MkdirAll(mdlDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
// Real rows. mustWrite := func(p string) {
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "D-001.yaml"), []byte("id: D-001\n"), 0o644); err != nil { if err := os.WriteFile(p, []byte("id: x\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "D-002.yaml"), []byte("id: D-002\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0440P2", "mdl", "D-010.yaml"), []byte("id: D-010\n"), 0o644); err != nil {
t.Fatal(err)
}
// Skipped: table.yaml, form.yaml, anything containing "__".
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "table.yaml"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "form.yaml"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projectAbs, "archive", "0330C1", "mdl", "weird__name.yaml"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
} }
mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "D-001.yaml"))
mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "D-002.yaml"))
mustWrite(filepath.Join(projectAbs, "mdl", "0440P2", "D-010.yaml"))
// Skipped: table.yaml / form.yaml specs.
mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "table.yaml"))
mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "form.yaml"))
rows, err := ListRollupRows(root, projectAbs, "mdl") rows, err := ListRollupRows(projectAbs, "mdl")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(rows) != 3 { if len(rows) != 3 {
t.Fatalf("got %d rows, want 3; rows=%+v", len(rows), rows) t.Fatalf("got %d rows, want 3; rows=%+v", len(rows), rows)
} }
wantNames := []string{"0330C1__D-001.yaml", "0330C1__D-002.yaml", "0440P2__D-010.yaml"} // Sorted by (party, filename); $party is the real subdir.
for i, want := range wantNames { want := []struct{ party, file, relURL string }{
if rows[i].SyntheticName != want { {"0330C1", "D-001.yaml", "/Project/mdl/0330C1/D-001.yaml"},
t.Errorf("row[%d].SyntheticName=%q want %q", i, rows[i].SyntheticName, want) {"0330C1", "D-002.yaml", "/Project/mdl/0330C1/D-002.yaml"},
{"0440P2", "D-010.yaml", "/Project/mdl/0440P2/D-010.yaml"},
}
for i, w := range want {
if rows[i].Party != w.party || rows[i].Filename != w.file || rows[i].RelURL != w.relURL {
t.Errorf("row[%d] = %+v want party=%q file=%q rel=%q", i, rows[i], w.party, w.file, w.relURL)
} }
} }
} }
func TestListRollupRows_BadSlot(t *testing.T) { func TestListRollupRows_BadPeer(t *testing.T) {
root := t.TempDir() root := t.TempDir()
if _, err := ListRollupRows(root, root, "ssr"); err == nil { if _, err := ListRollupRows(filepath.Join(root, "Project"), "ssr"); err == nil {
t.Error("expected error for slot=ssr (only mdl/rsk valid)") t.Error("expected error for peer=ssr (only mdl/rsk valid)")
} }
} }

View file

@ -88,6 +88,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.DropTarget != nil { if top.DropTarget != nil {
out.DropTarget = top.DropTarget out.DropTarget = top.DropTarget
} }
if top.PartySource != "" {
out.PartySource = top.PartySource
}
if top.History != nil { if top.History != nil {
out.History = top.History out.History = top.History
} }