refactor: nest lifecycle slots per-party + add virtual top-level aggregators

May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:

  ssr/mdl/rsk           tables rollups across parties with a
                        synthesised $party source-party column
  working/staging/      browse folder-nav listings of parties with
  reviewing             non-empty content in the slot; per-party
                        URLs 302-redirect to archive/<party>/<slot>/

Mkdir at the project root is restricted to `archive` and `_`/`.`-
prefixed system names — virtual aggregator names and ad-hoc folders
return 409.

Plan Review hardcodes the scaffold convention (archive/<party>/
{reviewing,staging}/<tracking>/); the pre-reshape
on_plan_review.{reviewing_root,staging_root} cascade keys are dropped.

document_controller is now subtree-admin of every archive/<party>/
(not of project-root working/staging/ as before), so per-party
lifecycle slots inherit admin authority through the cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 07:57:45 -05:00
parent 703449adc5
commit 59b5550872
34 changed files with 1239 additions and 727 deletions

View file

@ -287,7 +287,7 @@ The build enforces lockstep mechanically (one command bumps all 8). The rules be
No install script. Two paths: No install script. Two paths:
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done. - **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor), `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".** - **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables row-rollups across parties, with a synthesised `$party` source-party column the tables tool renders read-only and strips before write) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party URLs 302-redirect to `archive/<party>/<slot>/`). Mkdir directly at the project root is restricted to `archive` and `_`/`.`-prefixed system names — virtual aggregator names and ad-hoc folders return 409. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
To override at any level, either: To override at any level, either:
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority). 1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
@ -491,7 +491,7 @@ roles:
- '*@acme.com' - '*@acme.com'
``` ```
The embedded cascade already grants `project_team: r` project-wide and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, subtree-admin on `working/`/`staging/`/`reviewing/`). Populating role members lights all of that up. The embedded cascade already grants `project_team: r` project-wide and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, subtree-admin of every `archive/<party>/` so they own each party's lifecycle slots — `working/`, `staging/`, `reviewing/`, `incoming/`). Populating role members lights all of that up.
**Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`): **Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`):
@ -605,7 +605,7 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
**URLs are case-insensitive.** The dispatcher canonicalizes `r.URL.Path` against on-disk casing before any handler runs (`zddc/internal/fs/resolve.go ResolveCanonical`). Per segment: lowercase variant wins if it exists on disk; otherwise exact-case wins; otherwise readdir+CI scan with the lowercase variant winning the tiebreak when multiple case variants are siblings on disk. Walk stops at the first segment that doesn't exist so virtual prefixes (`.archive`, `.profile`, `.tokens`, `.api`, `.auth`) and 404 paths flow through with their tail preserved verbatim. **URLs are case-insensitive.** The dispatcher canonicalizes `r.URL.Path` against on-disk casing before any handler runs (`zddc/internal/fs/resolve.go ResolveCanonical`). Per segment: lowercase variant wins if it exists on disk; otherwise exact-case wins; otherwise readdir+CI scan with the lowercase variant winning the tiebreak when multiple case variants are siblings on disk. Walk stops at the first segment that doesn't exist so virtual prefixes (`.archive`, `.profile`, `.tokens`, `.api`, `.auth`) and 404 paths flow through with their tail preserved verbatim.
**File and folder names preserve case on disk.** The canonicalization is purely a URL→filesystem-name mapping; nothing renames anything. Lowercase is the *project-wide canonical* convention, and auto-created folders in `internal/zddc/ensure.go` (`working/`, `staging/`, `archive/<party>/incoming/`) and the server's own state dirs (`_app/`, `.zddc.d/tokens/`, `.zddc.d/outbox/`, `.zddc.d/logs/`) are all lowercase by string literal. Operators can drop a `Mixed-Case-Folder/` and it stays mixed-case. **File and folder names preserve case on disk.** The canonicalization is purely a URL→filesystem-name mapping; nothing renames anything. Lowercase is the *project-wide canonical* convention, and auto-created folders in `internal/zddc/ensure.go` (the per-party `archive/<party>/{working,staging,reviewing,incoming}/`) and the server's own state dirs (`_app/`, `.zddc.d/tokens/`, `.zddc.d/outbox/`, `.zddc.d/logs/`) are all lowercase by string literal. Operators can drop a `Mixed-Case-Folder/` and it stays mixed-case.
**Audit log captures the as-typed path.** `AccessLogMiddleware` snapshots `r.URL.Path` before dispatch rewrites it; the audit record's `path` field is what the client sent. When canonicalization changed it, a `resolved_path` field is added. **Audit log captures the as-typed path.** `AccessLogMiddleware` snapshots `r.URL.Path` before dispatch rewrites it; the audit record's `path` field is what the client sent. When canonicalization changed it, a `resolved_path` field is added.

View file

@ -679,7 +679,16 @@ The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypa
#### Canonical folders, URL routing & the `.zddc` cascade #### Canonical folders, URL routing & the `.zddc` cascade
There are **no hardcoded folder names** — the canonical project structure (`archive/`, `working/`, `staging/`, `reviewing/`; `archive/<party>/{mdl,incoming,received,issued}/`) is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults.zddc.yaml`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` dumps it; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument. There are **no hardcoded folder names** — the canonical project structure is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults.zddc.yaml`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` dumps it; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument.
**Project shape (after the May 2026 reshape).** `archive/` is the only physical project-root directory. Everything party-scoped lives uniformly under `archive/<party>/{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working/<email>/, staging/<batch>/, reviewing/<tracking>/}`. Six sibling top-level URLs are **virtual aggregators**, never on disk:
- **Row rollups** (tables tool, `default_tool: tables`) — `<project>/ssr`, `<project>/mdl`, `<project>/rsk`. Synthesise one row per party (SSR) or per row file across parties (MDL/RSK), with the source party injected as a synthesised `$party` column. The `$` sigil marks the column system-managed: the tables tool renders it read-only and strips it before submitting a write. Form-mode "+ Add row" on a rollup view prompts for `party` (the routing key, stored in the form schema as a real input field; stripped on write because the folder name *is* the identity).
- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `<project>/working`, `<project>/staging`, `<project>/reviewing`. List the parties whose `archive/<party>/<slot>/` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `<project>/<slot>/<party>[/<rest>]` 302-redirect to the canonical `<project>/archive/<party>/<slot>[/<rest>]`. No writes through the virtual URL space; sharing/bookmarks land on the canonical path after the redirect.
Mkdir at the project root is restricted: only `archive` and `_`/`.`-prefixed system names are accepted (`handler/fileapi.go: rejectProjectRootMkdir`). Any other name — including the six virtual aggregator names, which would shadow the virtual surface — returns 409 Conflict. This is the only structural mkdir guard; deeper paths are governed by `auto_own:` + `worm:` + ACL.
Plan Review (`X-ZDDC-Op: plan-review`) hardcodes the scaffold convention: workflow folders always land at `<project>/archive/<party>/{reviewing,staging}/<tracking>/`, derived from the originating submittal's path. The pre-reshape `on_plan_review.reviewing_root` / `staging_root` cascade keys were dropped — one convention, no per-project override surface. The `X-ZDDC-On-Plan-Review` response header (set by `directory.go`) lights up on every `/<project>/archive/<party>/received/<tracking>/` URL via the structural `zddc.IsPlanReviewURL` test, so the browse client knows when to show the menu item without re-implementing the cascade.
The schema keys that drive built-in behavior: The schema keys that drive built-in behavior:
@ -696,7 +705,7 @@ The schema keys that drive built-in behavior:
| `roles` | `{ name → { members:[], reset:bool } }` | members union across cascade; `reset:true` stops the walk | | `roles` | `{ name → { members:[], reset:bool } }` | members union across cascade; `reset:true` stops the walk |
| `paths` | recursive map of child-path → `.zddc` overlay; the engine of the whole convention | replaces (the walker threads ancestor `paths:` to the right level) | | `paths` | recursive map of child-path → `.zddc` overlay; the engine of the whole convention | replaces (the walker threads ancestor `paths:` to the right level) |
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor plugin), `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side. **Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` rollups). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
**Zip-backed directories.** A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON listing of the zip's members (or the browse SPA for an HTML request) and `GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member — so a client navigating a zipped transmittal folder never downloads the whole archive. `GET …/Foo.zip` (no trailing slash) is unchanged: the raw `.zip` download. Read-only: `PUT`/`DELETE`/`POST` to a path inside a `.zip` is rejected (405). ACL is the chain of the directory *containing* the zip — a zip carries no `.zddc` of its own, the same model as the `.archive` virtual surface. Implemented by `internal/zipfs` + `handler.ServeZip`, routed via `splitZipPath` in the dispatcher (before the file-API branch). Offline tools (archive's scanner, browse's tree) get the same capability client-side via `shared/zip-source.js` — a `ZipDirectoryHandle`/`ZipFileHandle` pair over JSZip that mimics the File-System-Access surface. The archive tool treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder; the browse tool expands *any* `.zip`. **Zip-backed directories.** A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON listing of the zip's members (or the browse SPA for an HTML request) and `GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member — so a client navigating a zipped transmittal folder never downloads the whole archive. `GET …/Foo.zip` (no trailing slash) is unchanged: the raw `.zip` download. Read-only: `PUT`/`DELETE`/`POST` to a path inside a `.zip` is rejected (405). ACL is the chain of the directory *containing* the zip — a zip carries no `.zddc` of its own, the same model as the `.archive` virtual surface. Implemented by `internal/zipfs` + `handler.ServeZip`, routed via `splitZipPath` in the dispatcher (before the file-API branch). Offline tools (archive's scanner, browse's tree) get the same capability client-side via `shared/zip-source.js` — a `ZipDirectoryHandle`/`ZipFileHandle` pair over JSZip that mimics the File-System-Access surface. The archive tool treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder; the browse tool expands *any* `.zip`.
@ -704,7 +713,7 @@ The schema keys that drive built-in behavior:
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change. **WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
**Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of `working/` and `staging/`, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule). **Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of every `archive/<party>/` and its in-flight slots, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `archive/<party>/working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule).
### File API (authenticated CRUD) ### File API (authenticated CRUD)

View file

@ -24,7 +24,7 @@ This is a **monorepo of independent tools**, not one application:
- `archive/`, `transmittal/`, `classifier/`, `landing/`, `form/`, `tables/`, `browse/` — seven self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). `form/` is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator and **also hosts the in-place markdown editor** (`browse/js/preview-markdown.js` — Toast UI Editor + YAML front-matter pane + on-demand server-side MD→DOCX/HTML/PDF download buttons). A dedicated `mdedit/` tool used to live alongside these but has been retired. See AGENTS.md "Form-data system" / "Tables system" and ARCHITECTURE.md "Form Renderer". - `archive/`, `transmittal/`, `classifier/`, `landing/`, `form/`, `tables/`, `browse/` — seven self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). `form/` is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator and **also hosts the in-place markdown editor** (`browse/js/preview-markdown.js` — Toast UI Editor + YAML front-matter pane + on-demand server-side MD→DOCX/HTML/PDF download buttons). A dedicated `mdedit/` tool used to live alongside these but has been retired. See AGENTS.md "Form-data system" / "Tables system" and ARCHITECTURE.md "Form Renderer".
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Two deployment shapes from the same binary: (1) **master** — owns a file tree under `ZDDC_ROOT`, applies `.zddc` ACL cascades, serves files / app HTML / archive listings. Two auth paths on master: `Authorization: Bearer <token>` validated against self-issued tokens at `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` for CLI/scripted callers, or `X-Auth-Request-Email` injected by an upstream proxy for browser sessions. Self-service token UI at `/.tokens` + JSON API at `/.api/tokens`. (2) **client** — when `--upstream <url>` is set, the binary becomes a downstream proxy/cache/mirror (`zddc/internal/cache/`); master-side machinery is bypassed and `--root` becomes the cache directory. Three sub-modes via `--mode proxy|cache|mirror` (mirror is phase 3). Cache layout is a normal ZDDC root, so the cache dir can be served as a plain master if you unset `--upstream`. Marker file `.zddc-upstream` records provenance. `--no-auth` skips ACL enforcement entirely on this instance (distinct from `--insecure` which only relaxes the no-root-`.zddc` startup check); `--skip-tls-verify` is a separate flag for self-signed upstream certs. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time. - `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Two deployment shapes from the same binary: (1) **master** — owns a file tree under `ZDDC_ROOT`, applies `.zddc` ACL cascades, serves files / app HTML / archive listings. Two auth paths on master: `Authorization: Bearer <token>` validated against self-issued tokens at `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` for CLI/scripted callers, or `X-Auth-Request-Email` injected by an upstream proxy for browser sessions. Self-service token UI at `/.tokens` + JSON API at `/.api/tokens`. (2) **client** — when `--upstream <url>` is set, the binary becomes a downstream proxy/cache/mirror (`zddc/internal/cache/`); master-side machinery is bypassed and `--root` becomes the cache directory. Three sub-modes via `--mode proxy|cache|mirror` (mirror is phase 3). Cache layout is a normal ZDDC root, so the cache dir can be served as a plain master if you unset `--upstream`. Marker file `.zddc-upstream` records provenance. `--no-auth` skips ACL enforcement entirely on this instance (distinct from `--insecure` which only relaxes the no-root-`.zddc` startup check); `--skip-tls-verify` is a separate flag for self-signed upstream certs. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
- `shared/` — CSS (`base.css`, `fonts.css` + base64-inlined woff2 under `fonts/`, `nav.css`, `logo.css`, `toast.css`) plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `zddc-source.js`, `zip-source.js`, `theme.js`, `toast.js`, `nav.js`, `logo.js`, `help.js`, `preview-lib.js`) and vendored libs (`vendor/`: jszip, xlsx, utif, docx-preview, toastui-editor) — each tool's `build.sh` concatenates the subset it needs. Also `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers). See AGENTS.md "Shared modules" for the full inventory. - `shared/` — CSS (`base.css`, `fonts.css` + base64-inlined woff2 under `fonts/`, `nav.css`, `logo.css`, `toast.css`) plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `zddc-source.js`, `zip-source.js`, `theme.js`, `toast.js`, `nav.js`, `logo.js`, `help.js`, `preview-lib.js`) and vendored libs (`vendor/`: jszip, xlsx, utif, docx-preview, toastui-editor) — each tool's `build.sh` concatenates the subset it needs. Also `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers). See AGENTS.md "Shared modules" for the full inventory.
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all seven HTML tools baked in via `//go:embed` (compile-time default). **Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded folder names** — a baked-in `defaults.zddc.yaml` (dump it: `zddc-server show-defaults`) declares, via a recursive `paths:` tree, per-folder `default_tool` (served at `<dir>``archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor), `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at root), `dir_tool` (served at `<dir>/`; defaults to `browse`), `available_tools`, plus the canonical-folder behaviour keys (`auto_own`, `worm:`, `virtual`, `drop_target`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/` → member listing; `…/Foo.zip/m.pdf` → that member); `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* via a `.zddc apps:` entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`; or drop a real `.html` at any path. See AGENTS.md "URL handling"/"Install model" and ARCHITECTURE.md "Canonical folders, URL routing & the `.zddc` cascade". - **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all seven HTML tools baked in via `//go:embed` (compile-time default). **Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded folder names** — a baked-in `defaults.zddc.yaml` (dump it: `zddc-server show-defaults`) declares, via a recursive `paths:` tree, per-folder `default_tool` (served at `<dir>``archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at root), `dir_tool` (served at `<dir>/`; defaults to `browse`), `available_tools`, plus the canonical-folder behaviour keys (`auto_own`, `worm:`, `virtual`, `drop_target`); operators override at any level. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables rollups across parties with a synthesized `$party` source-party column) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party clicks 302-redirect to `archive/<party>/<slot>/`). Mkdir at project root is restricted to `archive` + `_`/`.`-prefixed system names; the six virtual names are rejected with 409. A `.zip` file is also a navigable directory (`GET …/Foo.zip/` → member listing; `…/Foo.zip/m.pdf` → that member); `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* via a `.zddc apps:` entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`; or drop a real `.html` at any path. See AGENTS.md "URL handling"/"Install model" and ARCHITECTURE.md "Canonical folders, URL routing & the `.zddc` cascade".
- `helm/` — example Helm charts for zddc-server. Three flavors: `zddc-server-prod/` (production master), `zddc-server-dev/` (development master with OverlayFS isolation), `zddc-server-cache/` (downstream client running in proxy/cache/mirror mode against an upstream master, with bearer token from a Kubernetes Secret). All compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo — the cache chart references a separately-created Secret for the bearer token. - `helm/` — example Helm charts for zddc-server. Three flavors: `zddc-server-prod/` (production master), `zddc-server-dev/` (development master with OverlayFS isolation), `zddc-server-cache/` (downstream client running in proxy/cache/mirror mode against an upstream master, with bearer token from a Kubernetes Secret). All compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo — the cache chart references a separately-created Secret for the bearer token.
- `tests/` — Playwright specs (Chromium only, requires File System Access API). `tests/schema.spec.js` validates `transmittal.schema.json` against canonical fixtures via `ajv` (only dev dep besides Playwright) - `tests/` — Playwright specs (Chromium only, requires File System Access API). `tests/schema.spec.js` validates `transmittal.schema.json` against canonical fixtures via `ajv` (only dev dep besides Playwright)

View file

@ -1,12 +1,21 @@
// stage.js — Stage and Unstage workflow modals. // stage.js — Stage and Unstage workflow modals.
// //
// Stage: move a file from working/<…>/ into a transmittal folder under // After the layout reshape, working/ and staging/ live INSIDE each
// staging/<…>/. Modal lists existing transmittal folders in staging/ // party folder: archive/<party>/working/<email>/<file> and
// plus a "New transmittal folder…" option that prompts for a ZDDC- // archive/<party>/staging/<batch>/<file>. Stage and Unstage are now
// conforming name and mkdirs it before the move. // per-party — the destination batch is always inside the SAME
// party's staging slot. The party context is read from the source
// file's path.
// //
// Unstage: move a file from staging/<transmittal>/ back to the user's // Stage: move a file from archive/<party>/working/<…> into a
// working/<email>/ home (overridable). // transmittal folder under archive/<party>/staging/<…>. Modal lists
// existing transmittal folders in the party's staging/ plus a "New
// transmittal folder…" option that prompts for a ZDDC-conforming
// name and mkdirs it before the move.
//
// Unstage: move a file from archive/<party>/staging/<transmittal>/
// back to the user's archive/<party>/working/<email>/ home
// (overridable).
// //
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite // Both reuse the existing X-ZDDC-Op: move primitive — no new composite
// endpoint is needed; the client just orchestrates one POST per file // endpoint is needed; the client just orchestrates one POST per file
@ -26,32 +35,37 @@
} }
// ── Scope detection: path-shape, not cascade-content ────────────── // ── Scope detection: path-shape, not cascade-content ──────────────
// A file is stageable if its containing folder lives under // A file is stageable if its path matches
// /<project>/working/<…>. Unstageable if it lives under // /<project>/archive/<party>/working/<…>. Unstageable if it
// /<project>/staging/<transmittal>/<…>. Both are path-shape // matches /<project>/archive/<party>/staging/<transmittal>/<…>.
// queries — content/ACL is enforced server-side. // Both are path-shape queries — content/ACL is enforced server-
// side.
function projectAndSubtree(path) { // projectPartySlot returns { project, party, slot, rest } when
// path matches /<project>/archive/<party>/<slot>/<rest…>, or
// null on non-match.
function projectPartySlot(path) {
var rel = path.replace(/^\/+|\/+$/g, '').split('/'); var rel = path.replace(/^\/+|\/+$/g, '').split('/');
if (rel.length < 2) return null; if (rel.length < 4) return null;
return { project: rel[0], subtree: rel[1], rest: rel.slice(2) }; if (rel[1].toLowerCase() !== 'archive') return null;
return { project: rel[0], party: rel[2], slot: rel[3], rest: rel.slice(4) };
} }
function isStageableFile(node) { function isStageableFile(node) {
if (!node || node.isDir || node.virtual) return false; if (!node || node.isDir || node.virtual) return false;
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
if (!tree) return false; if (!tree) return false;
var p = projectAndSubtree(tree.pathFor(node)); var p = projectPartySlot(tree.pathFor(node));
return !!(p && p.subtree === 'working' && p.rest.length >= 1); return !!(p && p.slot === 'working' && p.rest.length >= 1);
} }
function isUnstageableFile(node) { function isUnstageableFile(node) {
if (!node || node.isDir || node.virtual) return false; if (!node || node.isDir || node.virtual) return false;
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
if (!tree) return false; if (!tree) return false;
var p = projectAndSubtree(tree.pathFor(node)); var p = projectPartySlot(tree.pathFor(node));
// staging/<transmittal-folder>/<file> — at least one folder // archive/<party>/staging/<transmittal-folder>/<file> — at
// segment between staging/ and the file. // least one folder segment between staging/ and the file.
return !!(p && p.subtree === 'staging' && p.rest.length >= 2); return !!(p && p.slot === 'staging' && p.rest.length >= 2);
} }
// ── Server helpers ───────────────────────────────────────────────── // ── Server helpers ─────────────────────────────────────────────────
@ -69,8 +83,9 @@
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
} }
async function fetchStagingFolders(project) { async function fetchStagingFolders(project, party) {
var entries = await listDir('/' + project + '/staging/'); var entries = await listDir(
'/' + project + '/archive/' + encodeURIComponent(party) + '/staging/');
return entries return entries
.filter(function (e) { return e && e.isDir; }) .filter(function (e) { return e && e.isDir; })
.map(function (e) { return e.name; }); .map(function (e) { return e.name; });
@ -256,14 +271,15 @@
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
if (!tree) return; if (!tree) return;
var srcUrl = tree.pathFor(node); var srcUrl = tree.pathFor(node);
var info = projectAndSubtree(srcUrl); var info = projectPartySlot(srcUrl);
if (!info || info.subtree !== 'working') { if (!info || info.slot !== 'working') {
status('Stage applies only to files under working/.', 'error'); status('Stage applies only to files under archive/<party>/working/.', 'error');
return; return;
} }
var stagingBase = '/' + info.project + '/staging/'; var stagingBase = '/' + info.project + '/archive/' +
encodeURIComponent(info.party) + '/staging/';
var folders; var folders;
try { folders = await fetchStagingFolders(info.project); } try { folders = await fetchStagingFolders(info.project, info.party); }
catch (e) { catch (e) {
status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error'); status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error');
return; return;
@ -290,20 +306,21 @@
status((e && e.message) || 'move failed', 'error'); status((e && e.message) || 'move failed', 'error');
return; return;
} }
status('Staged ' + node.name + ' → staging/' + choice.folderName + '/ — reload to see the move.', 'success'); status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/ — reload to see the move.', 'success');
} }
async function invokeUnstage(node) { async function invokeUnstage(node) {
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
if (!tree) return; if (!tree) return;
var srcUrl = tree.pathFor(node); var srcUrl = tree.pathFor(node);
var info = projectAndSubtree(srcUrl); var info = projectPartySlot(srcUrl);
if (!info || info.subtree !== 'staging') { if (!info || info.slot !== 'staging') {
status('Unstage applies only to files under staging/.', 'error'); status('Unstage applies only to files under archive/<party>/staging/.', 'error');
return; return;
} }
var email = await fetchSelfEmail(); var email = await fetchSelfEmail();
var defaultTarget = '/' + info.project + '/working/' + (email || '') + '/'; var defaultTarget = '/' + info.project + '/archive/' +
encodeURIComponent(info.party) + '/working/' + (email || '') + '/';
var choice; var choice;
try { try {
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget }); choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });

View file

@ -218,6 +218,17 @@
const col = colAt(c); const col = colAt(c);
if (!row || !col) return; if (!row || !col) return;
// $-prefixed columns are system-synthesized fields (e.g. the
// `$party` source-party qualifier on project-rollup MDL/RSK
// views). Their value is derived from the row's canonical
// path on read and stripped before any write — editing them
// would have no effect on disk, so suppress entry to edit
// mode entirely. Selection still works for keyboard
// navigation across the cell.
if (typeof col.field === 'string' && col.field.charAt(0) === '$') {
return;
}
const propSchema = propertySchemaFor(col); const propSchema = propertySchemaFor(col);
// Complex-type cells (nested object, generic array, oneOf) // Complex-type cells (nested object, generic array, oneOf)

View file

@ -57,7 +57,17 @@
// form-mode and never produce drafts here, so drafts only // form-mode and never produce drafts here, so drafts only
// contain primitive / string-array values that are safe to // contain primitive / string-array values that are safe to
// overwrite the corresponding top-level field. // overwrite the corresponding top-level field.
return Object.assign({}, data || {}, drafts || {}); //
// $-prefixed keys are system-synthesised on read (e.g. `$party`
// injected by the server's virtual-view handler on project-
// rollup MDL/RSK rows). They are not part of the row's stored
// YAML and would be rejected by the schema's additionalProperties
// rule. Strip them before sending the write.
const merged = Object.assign({}, data || {}, drafts || {});
for (const k of Object.keys(merged)) {
if (k.charAt(0) === '$') delete merged[k];
}
return merged;
} }
function rowFromState(rowId) { function rowFromState(rowId) {

View file

@ -52,7 +52,7 @@ test.describe('shared/logo.js', () => {
}); });
test('wraps with href=/<project> when inside a project subtree', async ({ page }) => { test('wraps with href=/<project> when inside a project subtree', async ({ page }) => {
await page.goto(`${baseUrl}/Project-1/working/casey/notes.md`, { waitUntil: 'load' }); await page.goto(`${baseUrl}/Project-1/archive/Acme/working/casey/notes.md`, { waitUntil: 'load' });
const got = await page.evaluate(() => { const got = await page.evaluate(() => {
const a = document.querySelector('.app-header__logo-link'); const a = document.querySelector('.app-header__logo-link');
return a && a.getAttribute('href'); return a && a.getAttribute('href');
@ -61,7 +61,7 @@ test.describe('shared/logo.js', () => {
}); });
test('the wrapper carries an aria-label matching its target', async ({ page }) => { test('the wrapper carries an aria-label matching its target', async ({ page }) => {
await page.goto(`${baseUrl}/Project-1/staging/`, { waitUntil: 'load' }); await page.goto(`${baseUrl}/Project-1/archive/Acme/staging/`, { waitUntil: 'load' });
const probe = await page.evaluate(() => { const probe = await page.evaluate(() => {
const a = document.querySelector('.app-header__logo-link'); const a = document.querySelector('.app-header__logo-link');
return a && { return a && {

View file

@ -1088,6 +1088,31 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return 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:
@ -1140,10 +1165,11 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
} }
} }
} }
// reviewing/ is no longer a virtual aggregator — it's a normal // (Top-level <project>/{working,staging,reviewing} URLs
// directory under each project, populated by the Plan Review // resolve as folder-nav virtuals — the per-party redirect
// composite endpoint with physical workflow folders. Falls // is handled above; the bare top-level URL falls through
// through to the canonical-folder block below. // to ServeDirectory, where ListDirectory synthesises the
// folder-nav listing from ListPartyDirsInSlot.)
// //
// Virtual received/ window. <workflow>/received/[...] is a // Virtual received/ window. <workflow>/received/[...] is a
// synthetic view onto the canonical received/<tracking>/ // synthetic view onto the canonical received/<tracking>/

View file

@ -201,17 +201,18 @@ func TestDispatchAppsResolution(t *testing.T) {
} }
// Folder availability rules: classifier should NOT be served at root // Folder availability rules: classifier should NOT be served at root
// (root has no Incoming/Working/Staging ancestor), but SHOULD work in // (root has no per-party working/staging/incoming ancestor), but
// /Project-A/Working/. // SHOULD work at /Project-A/archive/<party>/working/ where the per-
// party cascade declares classifier available.
rec5 := httptest.NewRecorder() rec5 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil)) dispatch(cfg, idx, ring, appsSrv, nil, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
if rec5.Code != http.StatusNotFound { if rec5.Code != http.StatusNotFound {
t.Errorf("/classifier.html at root: status=%d, want 404 (not in Incoming/Working/Staging)", rec5.Code) t.Errorf("/classifier.html at root: status=%d, want 404 (not in per-party working/staging/incoming)", rec5.Code)
} }
rec6 := httptest.NewRecorder() rec6 := httptest.NewRecorder()
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil)) dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/archive/Acme/Working/classifier.html", nil))
if rec6.Code != http.StatusOK { if rec6.Code != http.StatusOK {
t.Errorf("/Project-A/Working/classifier.html: status=%d, want 200", rec6.Code) t.Errorf("/Project-A/archive/Acme/Working/classifier.html: status=%d, want 200", rec6.Code)
} }
} }
@ -617,21 +618,18 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
}) })
} }
// No-trailing-slash form on a canonical folder → default app // No-trailing-slash form on a canonical folder → default app.
// (browse for working/+reviewing/, transmittal for staging/, // Under the reshape, the project-root staging/reviewing/working
// archive for archive/). Mirror of the existing "no-slash → // URLs are folder-nav virtuals served by browse (the per-party
// default app" behavior at the IsDir branch, extended to cover // transmittal default lives at archive/<party>/staging/). archive/
// the case where the folder doesn't exist on disk yet. // is still the archive tool.
noSlashDefaultApp := []struct { noSlashDefaultApp := []struct {
stage string stage string
expect string // substring that should appear in the response body expect string // substring that should appear in the response body
}{ }{
{"working", "ZDDC Browse"}, {"working", "ZDDC Browse"},
{"staging", "ZDDC Transmittal"}, {"staging", "ZDDC Browse"},
{"archive", "ZDDC Archive"}, {"archive", "ZDDC Archive"},
// reviewing/ also routes to browse (markdown editor lives
// inside it now); the polyfill follows the virtual aggregator's
// listing into canonical archive/+staging paths from there.
{"reviewing", "ZDDC Browse"}, {"reviewing", "ZDDC Browse"},
} }
for _, tc := range noSlashDefaultApp { for _, tc := range noSlashDefaultApp {

View file

@ -37,23 +37,36 @@ func AppAvailableAt(root, requestDir, app string) bool {
// which app to serve at a directory URL with no trailing slash — // which app to serve at a directory URL with no trailing slash —
// trailing-slash URLs serve the browse app for any directory. // trailing-slash URLs serve the browse app for any directory.
// //
// Rules (case-insensitive on canonical folder names): // Rules (case-insensitive on canonical folder names), under the
// May 2026 reshape:
// //
// - <project>/archive/<party>/mdl/... → "tables" // - <project>/archive/<party>/{mdl,rsk}/... → "tables"
// - <project>/archive/ → "archive" // - <project>/archive/<party>/staging/... → "transmittal"
// - <project>/archive/<party>/... → "archive" // - <project>/archive/<party>/{working,reviewing}/...
// - <project>/staging/... → "transmittal" // → "browse" (hosts the
// - <project>/working/... → "browse" (hosts the // markdown editor plugin)
// markdown editor plugin) // - <project>/archive/<party>/incoming/... → "classifier"
// - <project>/reviewing/... → "browse" (operates on the // - <project>/archive/<party>/{received,issued}/...
// virtual aggregator listing) // → "archive"
// - any other directory → "" (no default) // - <project>/archive/ → "archive"
// - <project>/{ssr,mdl,rsk} → "tables" (project-
// level rollup virtuals
// with synthesized
// $party column)
// - <project>/{working,staging,reviewing} → "browse" (project-
// level folder-nav
// virtuals — per-party
// URLs 302 to the
// canonical archive/
// <party>/<slot>/)
// - any other directory → "" (no default)
// //
// The mdl rule wins over the broader archive rule because the table // The {mdl,rsk} rule wins over the broader archive rule because the
// editor is a more specific surface for browsing planned deliverables // table editor is a more specific surface for browsing planned
// than the archive index. Note: the dir at archive/<party>/mdl/ // deliverables than the archive index. Note: the dir at
// itself IS the table — its table.yaml + form.yaml + row YAMLs all // archive/<party>/mdl/ itself IS the table — its table.yaml +
// live there together (self-contained directory). // form.yaml + row YAMLs all live there together (self-contained
// directory).
// //
// requestDir and root are absolute filesystem paths; requestDir must // requestDir and root are absolute filesystem paths; requestDir must
// be under root (otherwise "" is returned). // be under root (otherwise "" is returned).

View file

@ -21,13 +21,13 @@ func TestAppAvailableAt(t *testing.T) {
{root, "landing", true}, {root, "landing", true},
{root + "/Project-A", "landing", false}, {root + "/Project-A", "landing", false},
// classifier: working/, staging/, archive/<party>/incoming/ and subtrees // classifier: per-party working/, staging/, incoming/ subtrees
{root, "classifier", false}, {root, "classifier", false},
{root + "/Project-A", "classifier", false}, {root + "/Project-A", "classifier", false},
{root + "/Project-A/working", "classifier", true}, {root + "/Project-A/archive/ACME/working", "classifier", true},
{root + "/Project-A/working/deep/nested/path", "classifier", true}, {root + "/Project-A/archive/ACME/working/deep/nested/path", "classifier", true},
{root + "/Project-A/staging", "classifier", true}, {root + "/Project-A/archive/ACME/staging", "classifier", true},
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "classifier", true}, {root + "/Project-A/archive/ACME/staging/2026-06-15_x (DFT) - y", "classifier", true},
{root + "/Project-A/archive/ACME/incoming", "classifier", true}, {root + "/Project-A/archive/ACME/incoming", "classifier", true},
{root + "/Project-A/archive/ACME/incoming/sub", "classifier", true}, {root + "/Project-A/archive/ACME/incoming/sub", "classifier", true},
{root + "/Project-A/archive/ACME/received", "classifier", false}, {root + "/Project-A/archive/ACME/received", "classifier", false},
@ -37,20 +37,20 @@ func TestAppAvailableAt(t *testing.T) {
// browse: universal — every directory has browse available // browse: universal — every directory has browse available
// (it's in the embedded-defaults baseline available_tools). // (it's in the embedded-defaults baseline available_tools).
{root + "/Project-A/working", "browse", true}, {root + "/Project-A/archive/ACME/working", "browse", true},
{root + "/Project-A/working/sub", "browse", true}, {root + "/Project-A/archive/ACME/working/sub", "browse", true},
{root + "/Project-A/staging", "browse", true}, {root + "/Project-A/archive/ACME/staging", "browse", true},
{root + "/Project-A/archive/ACME/incoming", "browse", true}, {root + "/Project-A/archive/ACME/incoming", "browse", true},
// transmittal: staging/ only // transmittal: per-party staging/ only
{root + "/Project-A/staging", "transmittal", true}, {root + "/Project-A/archive/ACME/staging", "transmittal", true},
{root + "/Project-A/staging/sub", "transmittal", true}, {root + "/Project-A/archive/ACME/staging/sub", "transmittal", true},
{root + "/Project-A/working", "transmittal", false}, {root + "/Project-A/archive/ACME/working", "transmittal", false},
{root + "/Project-A/archive/ACME/issued", "transmittal", false}, {root + "/Project-A/archive/ACME/issued", "transmittal", false},
// case-fold: any case of canonical names matches // case-fold: any case of canonical names matches
{root + "/Project-A/Staging", "transmittal", true}, {root + "/Project-A/archive/ACME/Staging", "transmittal", true},
{root + "/Project-A/STAGING", "transmittal", true}, {root + "/Project-A/archive/ACME/STAGING", "transmittal", true},
{root + "/Project-A/archive/ACME/Incoming", "classifier", true}, {root + "/Project-A/archive/ACME/Incoming", "classifier", true},
{root + "/Project-A/Archive/ACME/incoming", "classifier", true}, {root + "/Project-A/Archive/ACME/incoming", "classifier", true},
@ -79,12 +79,20 @@ func TestDefaultAppAt(t *testing.T) {
// Bare project root: no default. Trailing-slash URL serves browse; // Bare project root: no default. Trailing-slash URL serves browse;
// no-slash falls through to the redirect. // no-slash falls through to the redirect.
{root + "/Project-A", ""}, {root + "/Project-A", ""},
// Canonical project-root folders. // Project-level virtual aggregators (sibling to archive/).
{root + "/Project-A/working", "browse"}, {root + "/Project-A/working", "browse"},
{root + "/Project-A/working/alice@example.com", "browse"}, {root + "/Project-A/staging", "browse"},
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "browse"}, {root + "/Project-A/reviewing", "browse"},
{root + "/Project-A/staging", "transmittal"}, {root + "/Project-A/ssr", "tables"},
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"}, {root + "/Project-A/mdl", "tables"},
{root + "/Project-A/rsk", "tables"},
// Per-party lifecycle slots (the real physical homes).
{root + "/Project-A/archive/Acme/working", "browse"},
{root + "/Project-A/archive/Acme/working/alice@example.com", "browse"},
{root + "/Project-A/archive/Acme/working/2026-06-15_x (DFT) - y", "browse"},
{root + "/Project-A/archive/Acme/staging", "transmittal"},
{root + "/Project-A/archive/Acme/staging/2026-06-15_x (DFT) - y", "transmittal"},
{root + "/Project-A/archive/Acme/reviewing", "browse"},
// archive: at the archive root, party folders default to archive. // archive: at the archive root, party folders default to archive.
// Per-party subfolders override per their function: // Per-party subfolders override per their function:
// incoming → classifier (the bulk-rename workflow) // incoming → classifier (the bulk-rename workflow)
@ -94,20 +102,15 @@ func TestDefaultAppAt(t *testing.T) {
{root + "/Project-A/archive/Acme/incoming", "classifier"}, {root + "/Project-A/archive/Acme/incoming", "classifier"},
{root + "/Project-A/archive/Acme/issued", "archive"}, {root + "/Project-A/archive/Acme/issued", "archive"},
{root + "/Project-A/archive/Acme/received", "archive"}, {root + "/Project-A/archive/Acme/received", "archive"},
// mdl wins over the broader archive rule. // mdl/rsk win over the broader archive rule.
{root + "/Project-A/archive/Acme/mdl", "tables"}, {root + "/Project-A/archive/Acme/mdl", "tables"},
{root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"}, {root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"},
// reviewing/ is virtual; browse hosts the markdown editor that {root + "/Project-A/archive/Acme/rsk", "tables"},
// renders responses (the polyfill follows the listing's
// canonical URLs into archive/ and staging/ for the actual
// files).
{root + "/Project-A/reviewing", "browse"},
{root + "/Project-A/reviewing/123-EM-SUB-0001", "browse"},
// Random non-canonical folder names → no default. // Random non-canonical folder names → no default.
{root + "/Project-A/scratch", ""}, {root + "/Project-A/scratch", ""},
// Case-fold on canonical names. // Case-fold on canonical names.
{root + "/Project-A/Working", "browse"}, {root + "/Project-A/archive/Acme/Working", "browse"},
{root + "/Project-A/STAGING", "transmittal"}, {root + "/Project-A/archive/Acme/STAGING", "transmittal"},
{root + "/Project-A/Archive/Acme/MDL", "tables"}, {root + "/Project-A/Archive/Acme/MDL", "tables"},
} }
for _, tc := range cases { for _, tc := range cases {

View file

@ -195,32 +195,39 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
result = append(result, fi) result = append(result, fi)
} }
// Per-user virtual home: when listing <project>/working/ for an // Per-user virtual home: when listing
// authenticated viewer, surface a synthetic <viewer-email>/ entry if // <project>/archive/<party>/working/ for an authenticated viewer,
// no real folder of any case variant already exists for them. A // surface a synthetic <viewer-email>/ entry if no real folder of
// first write to that path materialises a real folder with auto-own // any case variant already exists for them. A first write to that
// .zddc; subsequent listings drop the synthetic entry naturally. // path materialises a real folder with auto-own .zddc; subsequent
// listings drop the synthetic entry naturally.
if syn, ok := virtualUserHomeEntry(fsRoot, dirPath, userEmail, baseURL, result); ok { if syn, ok := virtualUserHomeEntry(fsRoot, dirPath, userEmail, baseURL, result); ok {
result = append(result, syn) result = append(result, syn)
} }
// At a project root, surface the four canonical project folders // At a project root, surface the cascade-declared top-level
// (archive/working/staging/reviewing) as virtual entries when no // folders (archive plus the six virtual aggregators) as virtual
// on-disk variant exists in any case. The browse client previously // entries when no on-disk variant exists. The browse client
// did this client-side; moving it server-side lets the directory's // previously did this client-side; moving it server-side lets the
// `display:` map apply to virtual entries the same way it applies // directory's `display:` map apply to virtual entries the same
// to real ones. // way it applies to real ones.
result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...) result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...)
// Project-level virtual table views: SSR aggregates one row per // Project-level virtual views:
// party folder under archive/; MDL/RSK rollups aggregate every //
// row from each party's mdl/ or rsk/. The listing surfaces // Row rollups (ssr/mdl/rsk) — synthesize row entries (Writable
// synthetic row entries (Writable bit per the canonical // bit per the canonical archive/<party>/ chain) plus synthetic
// archive/<party>/ chain) plus synthetic table.yaml/form.yaml // table.yaml/form.yaml entries so the tables tool's client-side
// entries so the tables tool's client-side walkServer finds the // walkServer finds the spec without a 404 round-trip. Spec bytes
// spec without a 404 round-trip. Spec bytes are served by the // come from main.go IsDefaultSpec fallback; row reads go through
// main.go IsDefaultSpec fallback; row reads go through // handler.ServeVirtualViewRow which path-injects name/$party.
// handler.ServeVirtualViewRow which path-injects name/party. //
// Folder-nav (working/staging/reviewing) — synthesize one
// IsDir=true entry per party whose archive/<party>/<slot>/ has
// non-empty content (in-flight filter). The browse client
// follows a click through to the virtual URL
// <project>/<slot>/<party>/ which the dispatcher 302s to the
// canonical archive/<party>/<slot>/.
if vv := zddc.ResolveVirtualView(fsRoot, strings.TrimSuffix(baseURL, "/")); vv.Resolved && vv.Kind.IsRootKind() { if vv := zddc.ResolveVirtualView(fsRoot, strings.TrimSuffix(baseURL, "/")); vv.Resolved && vv.Kind.IsRootKind() {
partyChains := make(map[string]zddc.PolicyChain) partyChains := make(map[string]zddc.PolicyChain)
chainFor := func(partyAbs string) zddc.PolicyChain { chainFor := func(partyAbs string) zddc.PolicyChain {
@ -252,6 +259,19 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
Writable: writable, Writable: writable,
}) })
} }
appendVirtualPartyDir := func(party, partyAbs string) {
dirURL := baseURL + url.PathEscape(party) + "/"
chain := chainFor(partyAbs)
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, dirURL); !allowed {
return
}
result = append(result, listing.FileInfo{
Name: party + "/",
URL: dirURL,
IsDir: true,
Virtual: true,
})
}
switch vv.Slot { switch vv.Slot {
case "ssr": case "ssr":
@ -266,12 +286,23 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party) partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party)
appendVirtualRow(row.SyntheticName, partyAbs) 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)
}
} }
result = append(result, // Row rollups carry synthetic spec entries so the tables tool
listing.FileInfo{Name: "table.yaml", URL: baseURL + "table.yaml", IsDir: false, Virtual: true}, // can walkServer them. Folder-nav virtuals don't need spec
listing.FileInfo{Name: "form.yaml", URL: baseURL + "form.yaml", IsDir: false, Virtual: true}, // files — they're just party listings rendered by browse.
) if zddc.IsRowSlot(vv.Slot) {
result = append(result,
listing.FileInfo{Name: "table.yaml", URL: baseURL + "table.yaml", IsDir: false, Virtual: true},
listing.FileInfo{Name: "form.yaml", URL: baseURL + "form.yaml", IsDir: false, Virtual: true},
)
}
} }
// Workflow folder: append a virtual `received/` entry whose backing // Workflow folder: append a virtual `received/` entry whose backing
@ -393,11 +424,18 @@ func virtualCanonicalFolders(fsRoot, absDir, baseURL string,
} }
// virtualUserHomeEntry returns the synthetic <viewer-email>/ entry that // virtualUserHomeEntry returns the synthetic <viewer-email>/ entry that
// should be appended to a working/ listing, or (zero, false) when no // should be appended to a per-party working/ listing, or (zero, false)
// synthetic entry applies. // 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: // Conditions for the entry to fire:
// - dirPath case-folds to <project>/working at depth-2 of fsRoot // - dirPath case-folds to <project>/archive/<party>/working at
// depth-4 of fsRoot
// - viewerEmail is non-empty // - viewerEmail is non-empty
// - real does not already contain a directory entry that case-folds // - real does not already contain a directory entry that case-folds
// to viewerEmail (so a materialised home doesn't get duplicated) // to viewerEmail (so a materialised home doesn't get duplicated)
@ -407,7 +445,9 @@ func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []l
} }
rel := strings.Trim(filepath.ToSlash(dirPath), "/") rel := strings.Trim(filepath.ToSlash(dirPath), "/")
parts := strings.Split(rel, "/") parts := strings.Split(rel, "/")
if len(parts) != 2 || !strings.EqualFold(parts[1], "working") { if len(parts) != 4 ||
!strings.EqualFold(parts[1], "archive") ||
!strings.EqualFold(parts[3], "working") {
return listing.FileInfo{}, false return listing.FileInfo{}, false
} }
for _, fi := range real { for _, fi := range real {

View file

@ -21,14 +21,20 @@ func setupTreeRoot(t *testing.T) string {
return root return root
} }
// Per-user homes now live at archive/<party>/working/<email>/ (depth-
// 4). The virtual entry fires when listing that path for a viewer
// whose home doesn't yet exist on disk.
func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) { func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
root := setupTreeRoot(t) root := setupTreeRoot(t)
if err := os.MkdirAll(filepath.Join(root, "Proj", "working"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false) got, err := ListDirectory(context.Background(), nil, root,
"Proj/archive/Acme/working", "alice@example.com",
"/Proj/archive/Acme/working/", false, false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -51,12 +57,14 @@ func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) { func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
root := setupTreeRoot(t) root := setupTreeRoot(t)
// A real folder exists for the viewer (any case). // A real folder exists for the viewer (any case).
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "Alice@Example.com"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "Alice@Example.com"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false) got, err := ListDirectory(context.Background(), nil, root,
"Proj/archive/Acme/working", "alice@example.com",
"/Proj/archive/Acme/working/", false, false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -69,12 +77,14 @@ func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) { func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
root := setupTreeRoot(t) root := setupTreeRoot(t)
if err := os.MkdirAll(filepath.Join(root, "Proj", "working"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false, false) got, err := ListDirectory(context.Background(), nil, root,
"Proj/archive/Acme/working", "" /* no viewer */,
"/Proj/archive/Acme/working/", false, false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -87,12 +97,14 @@ func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) { func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
root := setupTreeRoot(t) root := setupTreeRoot(t)
if err := os.MkdirAll(filepath.Join(root, "Proj", "staging"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "staging"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false, false) got, err := ListDirectory(context.Background(), nil, root,
"Proj/archive/Acme/staging", "alice@example.com",
"/Proj/archive/Acme/staging/", false, false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -105,15 +117,15 @@ func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) { func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
root := setupTreeRoot(t) root := setupTreeRoot(t)
// Listing inside working/ at depth 3+ — no synthetic entry should fire. // Listing inside working/<email>/ — no synthetic entry should fire.
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "alice@example.com"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, got, err := ListDirectory(context.Background(), nil, root,
"Proj/working/alice@example.com", "alice@example.com", "Proj/archive/Acme/working/alice@example.com", "alice@example.com",
"/Proj/working/alice@example.com/", false, false) "/Proj/archive/Acme/working/alice@example.com/", false, false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -126,13 +138,15 @@ func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) { func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
root := setupTreeRoot(t) root := setupTreeRoot(t)
// Pre-existing PascalCase Working/. // Pre-existing PascalCase Working/ under archive/<party>/.
if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "Working"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false, false) got, err := ListDirectory(context.Background(), nil, root,
"Proj/archive/Acme/Working", "alice@example.com",
"/Proj/archive/Acme/Working/", false, false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -147,14 +161,17 @@ func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
} }
} }
// Listing a canonical project folder that doesn't exist on disk yet // Listing a canonical-folder path that doesn't exist on disk yet
// returns an empty slice instead of os.ErrNotExist. The stage-strip // returns an empty slice instead of os.ErrNotExist. The stage-strip
// nav links into <project>/working/ etc. unconditionally; this keeps // nav links into <project>/archive/ etc. unconditionally; this keeps
// fresh projects (no working/ on disk yet) from 404'ing. // fresh projects from 404'ing.
//
// The synthetic per-user home entry fires for the in-party working
// slot; other canonical slots return a plain empty listing.
func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) { func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
root := setupTreeRoot(t) root := setupTreeRoot(t)
// Proj exists but Proj/working/ does NOT. // Proj exists; the party folder skeleton does not.
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"), if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"),
@ -163,29 +180,31 @@ func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
} }
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
for _, stage := range []string{"working", "staging", "reviewing", "archive"} { for _, stage := range []string{"working", "staging", "reviewing", "incoming"} {
dirPath := "Proj/archive/Acme/" + stage
baseURL := "/" + dirPath + "/"
got, err := ListDirectory(context.Background(), nil, root, got, err := ListDirectory(context.Background(), nil, root,
"Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false, false) dirPath, "alice@example.com", baseURL, false, false)
if err != nil { if err != nil {
t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err) t.Errorf("ListDirectory(%s) on missing dir: err = %v, want nil", dirPath, err)
continue continue
} }
// working/ surfaces a synthetic <viewer-email>/ entry; the others // working/ surfaces a synthetic <viewer-email>/ entry; the
// should be a flat empty listing. // others should be a flat empty listing.
if stage == "working" { if stage == "working" {
if len(got) != 1 || !got[0].Virtual { if len(got) != 1 || !got[0].Virtual {
t.Errorf("ListDirectory(Proj/working) on missing dir: got %+v, want only the virtual home entry", got) t.Errorf("ListDirectory(%s) on missing dir: got %+v, want only the virtual home entry", dirPath, got)
} }
} else { } else {
if len(got) != 0 { if len(got) != 0 {
t.Errorf("ListDirectory(Proj/%s) on missing dir: got %+v, want empty", stage, got) t.Errorf("ListDirectory(%s) on missing dir: got %+v, want empty", dirPath, got)
} }
} }
} }
} }
// Non-canonical paths still 404 (return os.ErrNotExist) — the fallback // Non-canonical paths still 404 (return os.ErrNotExist) — the fallback
// only applies to the four canonical project-root folders. // only applies to cascade-declared paths.
func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) { func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
root := setupTreeRoot(t) root := setupTreeRoot(t)
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
@ -204,3 +223,37 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err) t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err)
} }
} }
// Project-level folder-nav virtual lists only the parties that have
// non-empty content in the slot. Empty/missing party slots are
// filtered out.
func TestListDirectory_VirtualFolderNav_FiltersInFlight(t *testing.T) {
root := setupTreeRoot(t)
// Acme has a populated working/; Beta is scaffolded but empty.
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Proj", "archive", "Acme", "working", "draft.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Beta", "working"), 0o755); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root,
"Proj/working", "alice@example.com", "/Proj/working/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
var partyDirs []string
for _, fi := range got {
if fi.IsDir && fi.Virtual {
partyDirs = append(partyDirs, fi.Name)
}
}
want := []string{"Acme/"}
if len(partyDirs) != 1 || partyDirs[0] != want[0] {
t.Errorf("project-level folder-nav listing = %v, want %v", partyDirs, want)
}
}

View file

@ -27,8 +27,9 @@ import (
// invariantsFixture sets up a synthetic ZDDC root with: // invariantsFixture sets up a synthetic ZDDC root with:
// //
// - admin@example.com — root super-admin // - admin@example.com — root super-admin
// - alice@example.com — subtree admin of Project-1/working (via per-dir // - alice@example.com — subtree admin of Project-1/archive/Acme/working
// .zddc admins:) — used to test subtree scope // (via per-dir .zddc admins:) — used to test
// subtree scope
// - bob@example.com — document_controller role member (gets WORM cr // - bob@example.com — document_controller role member (gets WORM cr
// on received/ + issued/ via cascade defaults) // on received/ + issued/ via cascade defaults)
// - eve@example.com — non-admin, project_team only (read-only across // - eve@example.com — non-admin, project_team only (read-only across
@ -47,7 +48,7 @@ func invariantsFixture(t *testing.T) (config.Config, string) {
" project_team:\n members: [\"*@example.com\"]\n") " project_team:\n members: [\"*@example.com\"]\n")
for _, d := range []string{ for _, d := range []string{
"Project-1/working/eve@example.com", "Project-1/archive/Acme/working/eve@example.com",
"Project-1/archive/Acme/received/Acme-0042", "Project-1/archive/Acme/received/Acme-0042",
"Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test", "Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test",
} { } {
@ -56,14 +57,14 @@ func invariantsFixture(t *testing.T) (config.Config, string) {
} }
} }
// Subtree-admin grant: alice administers Project-1/working/. // Subtree-admin grant: alice administers Project-1/archive/Acme/working/.
mustWriteHelper(t, mustWriteHelper(t,
filepath.Join(root, "Project-1/working/.zddc"), filepath.Join(root, "Project-1/archive/Acme/working/.zddc"),
"admins:\n - alice@example.com\n") "admins:\n - alice@example.com\n")
// Files to act on. // Files to act on.
mustWriteHelper(t, mustWriteHelper(t,
filepath.Join(root, "Project-1/working/eve@example.com/draft.md"), filepath.Join(root, "Project-1/archive/Acme/working/eve@example.com/draft.md"),
"# eve's draft\n") "# eve's draft\n")
mustWriteHelper(t, mustWriteHelper(t,
filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"), filepath.Join(root, "Project-1/archive/Acme/received/Acme-0042/Acme-0042_A (RFI) - Test.pdf"),
@ -114,7 +115,7 @@ func TestInvariant_UnelevatedAdminCannotEditZddc(t *testing.T) {
// Exercised at the HTTP boundary: a PUT to .zddc from an un-elevated // Exercised at the HTTP boundary: a PUT to .zddc from an un-elevated
// super-admin must return Forbidden. // super-admin must return Forbidden.
cfg, _ := invariantsFixture(t) cfg, _ := invariantsFixture(t)
target := "/Project-1/working/.zddc" target := "/Project-1/archive/Acme/working/.zddc"
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("title: mutated\n"), "") rec := doReq(cfg, http.MethodPut, target, "admin@example.com", false, []byte("title: mutated\n"), "")
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
t.Fatalf("un-elevated admin .zddc write succeeded: status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("un-elevated admin .zddc write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
@ -126,7 +127,7 @@ func TestInvariant_ElevatedAdminCanEditZddc(t *testing.T) {
// .zddc. The decider's IsActiveAdmin short-circuit fires in // .zddc. The decider's IsActiveAdmin short-circuit fires in
// AllowActionFromChainP and the file API write proceeds. // AllowActionFromChainP and the file API write proceeds.
cfg, _ := invariantsFixture(t) cfg, _ := invariantsFixture(t)
target := "/Project-1/working/.zddc" target := "/Project-1/archive/Acme/working/.zddc"
rec := doReq(cfg, http.MethodPut, target, "admin@example.com", true, []byte("title: elevated edit\n"), "") rec := doReq(cfg, http.MethodPut, target, "admin@example.com", true, []byte("title: elevated edit\n"), "")
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated { if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("elevated admin .zddc write blocked: status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("elevated admin .zddc write blocked: status=%d body=%s", rec.Code, rec.Body.String())
@ -148,9 +149,9 @@ func TestInvariant_ElevatedAdminBypassesWorm(t *testing.T) {
func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.T) { func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.T) {
cfg, _ := invariantsFixture(t) cfg, _ := invariantsFixture(t)
target := "/Project-1/working/eve@example.com/draft.md" target := "/Project-1/archive/Acme/working/eve@example.com/draft.md"
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# alice override\n"), "") rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# alice override\n"), "")
// alice is subtree admin of Project-1/working/ — should override eve's // alice is subtree admin of Project-1/archive/Acme/working/ — should override eve's
// fenced auto-own and write through. // fenced auto-own and write through.
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated { if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
t.Fatalf("elevated subtree admin write in scope blocked: status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("elevated subtree admin write in scope blocked: status=%d body=%s", rec.Code, rec.Body.String())
@ -159,7 +160,7 @@ func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.T) {
func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) { func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) {
cfg, _ := invariantsFixture(t) cfg, _ := invariantsFixture(t)
// alice is subtree admin of /Project-1/working/, NOT of /Project-1/archive/. // alice is subtree admin of /Project-1/archive/Acme/working/, NOT of /Project-1/archive/.
target := "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md" target := "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md"
rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# out-of-scope\n"), "") rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# out-of-scope\n"), "")
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
@ -178,7 +179,7 @@ func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) {
func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) { func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) {
cfg, _ := invariantsFixture(t) cfg, _ := invariantsFixture(t)
p := zddc.Principal{Email: "alice@example.com", Elevated: true} p := zddc.Principal{Email: "alice@example.com", Elevated: true}
dir := filepath.Join(cfg.Root, "Project-1/working") dir := filepath.Join(cfg.Root, "Project-1/archive/Acme/working")
chain, err := zddc.EffectivePolicy(cfg.Root, dir) chain, err := zddc.EffectivePolicy(cfg.Root, dir)
if err != nil { if err != nil {
t.Fatalf("EffectivePolicy: %v", err) t.Fatalf("EffectivePolicy: %v", err)
@ -191,7 +192,7 @@ func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) {
func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) { func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) {
cfg, _ := invariantsFixture(t) cfg, _ := invariantsFixture(t)
p := zddc.Principal{Email: "alice@example.com", Elevated: true} p := zddc.Principal{Email: "alice@example.com", Elevated: true}
dir := filepath.Join(cfg.Root, "Project-1/working/eve@example.com") dir := filepath.Join(cfg.Root, "Project-1/archive/Acme/working/eve@example.com")
chain, err := zddc.EffectivePolicy(cfg.Root, dir) chain, err := zddc.EffectivePolicy(cfg.Root, dir)
if err != nil { if err != nil {
t.Fatalf("EffectivePolicy: %v", err) t.Fatalf("EffectivePolicy: %v", err)
@ -205,7 +206,7 @@ func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) {
func TestInvariant_EmptyEmailHasNoAuthority(t *testing.T) { func TestInvariant_EmptyEmailHasNoAuthority(t *testing.T) {
cfg, _ := invariantsFixture(t) cfg, _ := invariantsFixture(t)
target := "/Project-1/working/eve@example.com/draft.md" target := "/Project-1/archive/Acme/working/eve@example.com/draft.md"
rec := doReq(cfg, http.MethodPut, target, "", true, []byte("# anon\n"), "") rec := doReq(cfg, http.MethodPut, target, "", true, []byte("# anon\n"), "")
if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized { if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized {
t.Fatalf("empty-email write succeeded: status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("empty-email write succeeded: status=%d body=%s", rec.Code, rec.Body.String())
@ -295,7 +296,7 @@ func TestInvariant_ForwardAuthEndpointGatesOnAdminsList(t *testing.T) {
// - /Project-1/.zddc — project file (no on-disk .zddc; // - /Project-1/.zddc — project file (no on-disk .zddc;
// write must materialise it; root // write must materialise it; root
// admins still govern via cascade) // admins still govern via cascade)
// - /Project-1/working/.zddc — subtree file; alice administers // - /Project-1/archive/Acme/working/.zddc — subtree file; alice administers
// this subtree via its own admins: // this subtree via its own admins:
// list (so alice's write doesn't // list (so alice's write doesn't
// require root-admin authority). // require root-admin authority).
@ -340,12 +341,12 @@ func TestInvariant_ZddcPutMatrix(t *testing.T) {
{"non-admin → project .zddc", "/Project-1/.zddc", nonAdmin, den}, {"non-admin → project .zddc", "/Project-1/.zddc", nonAdmin, den},
// Subtree .zddc (alice administers this subtree) // Subtree .zddc (alice administers this subtree)
{"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, ok}, {"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, ok},
{"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, den}, {"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, den},
{"subtree admin elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, ok}, {"subtree admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, ok},
{"subtree admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, den}, {"subtree admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, den},
{"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, den}, {"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, den},
{"anonymous → subtree .zddc", "/Project-1/working/.zddc", anon, den}, {"anonymous → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", anon, den},
} }
for _, tc := range cases { for _, tc := range cases {
@ -386,11 +387,11 @@ func TestInvariant_ZddcDeleteMatrix(t *testing.T) {
who principal who principal
want int want int
}{ }{
{"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, http.StatusNoContent}, {"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, http.StatusNoContent},
{"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, http.StatusForbidden}, {"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, http.StatusForbidden},
{"subtree admin elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, http.StatusNoContent}, {"subtree admin elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, http.StatusNoContent},
{"subtree admin un-elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden}, {"subtree admin un-elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden},
{"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, http.StatusForbidden}, {"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, http.StatusForbidden},
} }
for _, tc := range cases { for _, tc := range cases {
@ -423,14 +424,14 @@ func TestInvariant_UnelevatedAdminNoSilentBypass(t *testing.T) {
probes := []op{ probes := []op{
// .zddc writes (ActionAdmin) // .zddc writes (ActionAdmin)
{http.MethodPut, "/.zddc", []byte("title: x\n"), ""}, {http.MethodPut, "/.zddc", []byte("title: x\n"), ""},
{http.MethodPut, "/Project-1/working/.zddc", []byte("title: x\n"), ""}, {http.MethodPut, "/Project-1/archive/Acme/working/.zddc", []byte("title: x\n"), ""},
{http.MethodDelete, "/Project-1/working/.zddc", nil, ""}, {http.MethodDelete, "/Project-1/archive/Acme/working/.zddc", nil, ""},
// WORM writes (ActionWrite / ActionCreate stripped) // WORM writes (ActionWrite / ActionCreate stripped)
{http.MethodPut, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", []byte("# mutate\n"), ""}, {http.MethodPut, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", []byte("# mutate\n"), ""},
{http.MethodPut, "/Project-1/archive/Acme/received/Acme-0042/new.pdf", []byte("%PDF\n"), ""}, {http.MethodPut, "/Project-1/archive/Acme/received/Acme-0042/new.pdf", []byte("%PDF\n"), ""},
{http.MethodDelete, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", nil, ""}, {http.MethodDelete, "/Project-1/archive/Acme/issued/2026-05-15_Acme-0099 (IFR) - Test/Acme-0099_0A (IFR) - Test.md", nil, ""},
// Regular write into someone else's working/ home (no ACL grant) // Regular write into someone else's working/ home (no ACL grant)
{http.MethodPut, "/Project-1/working/eve@example.com/draft.md", []byte("# steal\n"), ""}, {http.MethodPut, "/Project-1/archive/Acme/working/eve@example.com/draft.md", []byte("# steal\n"), ""},
} }
admins := []struct { admins := []struct {

View file

@ -4,21 +4,23 @@
# #
# This view aggregates every deliverable row from every party under # This view aggregates every deliverable row from every party under
# <project>/archive/. Each synthetic row is backed by the real file # <project>/archive/. Each synthetic row is backed by the real file
# at <project>/archive/<party>/mdl/<file>.yaml; the leading `party` # at <project>/archive/<party>/mdl/<file>.yaml; the leading `$party`
# column is derived from the row's source folder (path-injected by # column is the server-synthesized source-party identity (path-
# the server, not stored in the YAML). # injected on read, not stored in the YAML). The `$` sigil marks it
# as system-managed — tables tool renders read-only and strips it
# before submitting a row write.
# #
# + Add row IS enabled here: the `party` column doubles as the # + Add row IS enabled here: the form schema's `party` field doubles
# routing key — the server reads the submitted `party` field, finds # as the routing key — the server reads the submitted `party` field,
# the matching <project>/archive/<party>/ folder, and writes the row # finds the matching <project>/archive/<party>/ folder, and writes
# inside its mdl/ subfolder. The party folder must already exist # the row inside its mdl/ subfolder. The party folder must already
# (create it via the SSR view). # exist (create it via the SSR view).
title: Project Deliverables (all parties) title: Project Deliverables (all parties)
description: Every deliverable across all parties under archive/. Click a row to edit; + Add row uses the Package column to route the new row to the matching archive/<party>/mdl/ folder. description: Every deliverable across all parties under archive/. Click a row to edit; + Add row uses the Package column to route the new row to the matching archive/<party>/mdl/ folder.
columns: columns:
- field: party - field: $party
title: Package title: Package
width: 7em width: 7em
- field: originator - field: originator
@ -64,5 +66,5 @@ columns:
defaults: defaults:
sort: sort:
- { field: party, dir: asc } - { field: $party, dir: asc }
- { field: plannedDate, dir: asc } - { field: plannedDate, dir: asc }

View file

@ -3,21 +3,23 @@
# #
# This view aggregates every risk row from every party under # This view aggregates every risk row from every party under
# <project>/archive/. Each synthetic row is backed by the real file # <project>/archive/. Each synthetic row is backed by the real file
# at <project>/archive/<party>/rsk/<file>.yaml; the leading `party` # at <project>/archive/<party>/rsk/<file>.yaml; the leading `$party`
# column is derived from the row's source folder (path-injected by # column is the server-synthesized source-party identity (path-
# the server, not stored in the YAML). # injected on read, not stored in the YAML). The `$` sigil marks it
# as system-managed — tables tool renders read-only and strips it
# before submitting a row write.
# #
# + Add row IS enabled here: the `party` column doubles as the # + Add row IS enabled here: the form schema's `party` field doubles
# routing key — the server reads the submitted `party` field, finds # as the routing key — the server reads the submitted `party` field,
# the matching <project>/archive/<party>/ folder, and writes the row # finds the matching <project>/archive/<party>/ folder, and writes
# inside its rsk/ subfolder. The party folder must already exist # the row inside its rsk/ subfolder. The party folder must already
# (create it via the SSR view). # exist (create it via the SSR view).
title: Project Risk Register (all parties) title: Project Risk Register (all parties)
description: Every risk across all parties under archive/. Click a row to edit; + Add row uses the Package column to route the new row to the matching archive/<party>/rsk/ folder. description: Every risk across all parties under archive/. Click a row to edit; + Add row uses the Package column to route the new row to the matching archive/<party>/rsk/ folder.
columns: columns:
- field: party - field: $party
title: Package title: Package
width: 7em width: 7em
- field: id - field: id
@ -52,4 +54,4 @@ columns:
defaults: defaults:
sort: sort:
- { field: severity, dir: desc } - { field: severity, dir: desc }
- { field: party, dir: asc } - { field: $party, dir: asc }

View file

@ -147,12 +147,16 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
if dt := zddc.DefaultToolAt(cfg.Root, absDir); dt != "" { if dt := zddc.DefaultToolAt(cfg.Root, absDir); dt != "" {
w.Header().Set("X-ZDDC-Default-Tool", dt) w.Header().Set("X-ZDDC-Default-Tool", dt)
} }
// X-ZDDC-On-Plan-Review surfaces whether the cascade above this // X-ZDDC-On-Plan-Review surfaces whether this path is eligible for
// path has an on_plan_review block configured. Browse uses it to // the Plan Review composite endpoint — true at every URL of the
// shape /<project>/archive/<party>/received/<tracking>/, which is
// the only shape the handler accepts. Browse uses the header to
// show/hide the "Plan Review" right-click menu item without // show/hide the "Plan Review" right-click menu item without
// re-implementing the cascade client-side. Boolean; absent header // duplicating the URL test client-side. Boolean; absent header =
// = false. // false. (Replaced the previous cascade-keyed on_plan_review check
if zddc.OnPlanReviewAt(cfg.Root, absDir) != nil { // when the layout reshape made archive/<party>/{reviewing,staging}/
// the hardcoded scaffold target — see handler/planreview.go.)
if zddc.IsPlanReviewURL(urlPath) {
w.Header().Set("X-ZDDC-On-Plan-Review", "true") w.Header().Set("X-ZDDC-On-Plan-Review", "true")
} }
// X-ZDDC-Canonical-Folder names the canonical project-layout slot // X-ZDDC-Canonical-Folder names the canonical project-layout slot

View file

@ -694,6 +694,17 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
// Project-root mkdir policy: the only physical child allowed
// directly under <project>/ is `archive` (plus _/.-prefixed
// system names). Mkdir of any other name — including the six
// virtual aggregator names (ssr/mdl/rsk/working/staging/reviewing)
// — 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 {
http.Error(w, why, http.StatusConflict)
return
}
// Resolve canonical-folder casing on the way in (no side effects). // Resolve canonical-folder casing on the way in (no side effects).
if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil { if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
abs = r2 abs = r2
@ -759,77 +770,63 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
} }
} }
// Staging↔working mirror: when a folder created under staging/ matches // (The pre-reshape staging↔working mirror was retired: with
// the ZDDC transmittal-folder grammar AND its tracking number contains // staging at archive/<party>/staging/<batch>/ and working at
// -SUB- or -TRN-, also create the same-named folder under working/ as // archive/<party>/working/<email>/, the project-level pairing
// a drafting space for staff. The mirror is one-way and one-shot — // no longer maps cleanly. Operators who want a per-batch drafting
// renames or deletions of either side are not propagated. // space create it inside their own working/<email>/ home.)
if email != "" {
mirrorStagingToWorking(cfg, abs, email)
}
w.Header().Set("X-ZDDC-Source", "fileapi:mkdir") w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil) auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil)
} }
// mirrorStagingToWorking creates a paired drafting folder under working/ // rejectProjectRootMkdir reports whether a mkdir at abs lands at
// when newAbs is a transmittal-named folder under <project>/staging/. Best // <project>/<name>/ where <name> is forbidden as a direct project-
// effort — failures are logged but do not affect the staging mkdir result. // root physical child. Under the canonical layout:
// //
// Eligibility: // - `archive` is the only physical project-root canonical folder
// - newAbs's parent is exactly <project>/staging/ (case-fold) // - `_`-/`.`-prefixed names are system-reserved and allowed
// - filepath.Base(newAbs) parses as a transmittal folder // - the six virtual aggregator names (ssr/mdl/rsk/working/staging/
// (YYYY-MM-DD_<tracking> (<status>) - <title>) // reviewing) are explicitly rejected — the virtual resolver
// - tracking contains -SUB- or -TRN- (case-fold) // would shadow any physical folder created at those URLs
// - any other name is rejected: project-root mkdir of an ad-hoc
// name was an artefact of the pre-reshape layout where doc
// controllers could create freeform top-level folders, but the
// new model treats the project root as exclusively system + the
// archive/ party-holder.
// //
// Side effects on success: // Returns (true, reason) when the request should be 409'd. Returns
// - <project>/working/ created if missing, with auto-own .zddc seeded // (false, "") when the target is at any other depth or carries an
// (via EnsureCanonicalAncestors) // allowed name.
// - <project>/working/<sameName>/ created if missing, with its own func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
// auto-own .zddc (it's a child of the working/ canonical folder) rel, err := filepath.Rel(fsRoot, abs)
func mirrorStagingToWorking(cfg config.Config, newAbs, email string) {
rel, err := filepath.Rel(cfg.Root, newAbs)
if err != nil { if err != nil {
return return false, ""
} }
rel = filepath.ToSlash(rel) rel = filepath.ToSlash(rel)
if rel == "." || strings.HasPrefix(rel, "../") {
return false, ""
}
parts := strings.Split(rel, "/") parts := strings.Split(rel, "/")
if len(parts) != 3 { if len(parts) != 2 {
// Mirror only fires for direct children of staging/. Deeper paths // Not a direct project-root child — depth-2 = <project>/<name>.
// (staging/<name>/sub/) are user-managed. return false, ""
return
} }
if !strings.EqualFold(parts[1], "staging") { name := parts[1]
return if name == "archive" {
return false, ""
} }
if strings.HasPrefix(name, "_") || strings.HasPrefix(name, ".") {
name := parts[2] // System-reserved namespace; allowed.
_, tracking, _, _, ok := zddc.ParseTransmittalFolder(name) return false, ""
if !ok || !zddc.IsTrnOrSubTracking(tracking) {
return
} }
lower := strings.ToLower(name)
mirrorPath := filepath.Join(cfg.Root, parts[0], "working", name) switch lower {
// Idempotent: skip if the working sibling already exists. case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
if info, err := os.Stat(mirrorPath); err == nil && info.IsDir() { 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
}
// EnsureCanonicalAncestors creates working/ (with its own auto-own .zddc)
// if missing; we then MkdirAll the mirror folder itself and seed its
// auto-own grant.
if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, mirrorPath, email, 0o755); err != nil {
slog.Warn("mirror: ensure ancestors", "path", mirrorPath, "err", err)
return
}
if err := os.MkdirAll(mirrorPath, 0o755); err != nil {
slog.Warn("mirror: mkdir", "path", mirrorPath, "err", err)
return
}
if err := zddc.WriteAutoOwnZddc(mirrorPath, email); err != nil {
slog.Warn("mirror: auto-own .zddc", "path", mirrorPath, "err", err)
} }
return true, "Conflict — only archive/ and system-reserved (_/. prefix) folders may be created directly under a project. Files belong inside archive/<party>/..."
} }
// auditFile emits a structured log line for each file API operation. // auditFile emits a structured log line for each file API operation.

View file

@ -7,7 +7,6 @@ import (
"encoding/hex" "encoding/hex"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -306,15 +305,18 @@ func TestFileAPI_PostMissingOp(t *testing.T) {
} }
func TestFileAPI_MkdirCreates(t *testing.T) { func TestFileAPI_MkdirCreates(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil) // Project-root mkdir is restricted to archive/ + system names
// after the layout reshape; test mkdir at a depth where the
// guard doesn't fire (under archive/<party>/incoming/).
_, do, root := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming"}, nil)
rec := do(http.MethodPost, "/Incoming/newfolder/", "alice@example.com", nil, map[string]string{ rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/newfolder/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir", "X-ZDDC-Op": "mkdir",
}) })
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
} }
info, err := os.Stat(filepath.Join(root, "Incoming/newfolder")) info, err := os.Stat(filepath.Join(root, "Proj/archive/Acme/incoming/newfolder"))
if err != nil { if err != nil {
t.Fatalf("stat: %v", err) t.Fatalf("stat: %v", err)
} }
@ -324,8 +326,8 @@ func TestFileAPI_MkdirCreates(t *testing.T) {
} }
func TestFileAPI_MkdirIdempotent(t *testing.T) { func TestFileAPI_MkdirIdempotent(t *testing.T) {
_, do, _ := fileAPITestSetup(t, []string{"Incoming/exists"}, nil) _, do, _ := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming/exists"}, nil)
rec := do(http.MethodPost, "/Incoming/exists/", "alice@example.com", nil, map[string]string{ rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/exists/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir", "X-ZDDC-Op": "mkdir",
}) })
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@ -333,6 +335,41 @@ func TestFileAPI_MkdirIdempotent(t *testing.T) {
} }
} }
// TestFileAPI_MkdirProjectRootGuard — direct mkdir at <project>/<name>/
// is restricted: archive/ and system names (_/.-prefix) are allowed,
// any other name (including the six virtual aggregator names) is
// rejected with 409.
func TestFileAPI_MkdirProjectRootGuard(t *testing.T) {
_, do, _ := fileAPITestSetup(t, []string{"Proj"}, nil)
// Reject ad-hoc name.
rec := do(http.MethodPost, "/Proj/notes/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusConflict {
t.Fatalf("want 409 for /Proj/notes/, got %d: %s", rec.Code, rec.Body.String())
}
// Reject each virtual aggregator name.
for _, name := range []string{"ssr", "mdl", "rsk", "working", "staging", "reviewing"} {
rec := do(http.MethodPost, "/Proj/"+name+"/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusConflict {
t.Fatalf("%s: want 409, got %d: %s", name, rec.Code, rec.Body.String())
}
}
// Allow archive/.
rec = do(http.MethodPost, "/Proj/archive/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("want 201 for /Proj/archive/, got %d: %s", rec.Code, rec.Body.String())
}
// `_`/`.`-prefixed system names are caught earlier (resolveTargetPath
// rejects them as reserved path segments with 404 — see fileapi.go
// resolveTargetPath); the mkdir guard would also allow them, so the
// composite end-state is reserved + 404. Tested elsewhere.
}
func TestFileAPI_IfMatchEnforced(t *testing.T) { func TestFileAPI_IfMatchEnforced(t *testing.T) {
_, do, _ := fileAPITestSetup(t, nil, map[string]string{ _, do, _ := fileAPITestSetup(t, nil, map[string]string{
"Incoming/x.txt": "v1", "Incoming/x.txt": "v1",
@ -630,145 +667,7 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
} }
} }
// --- staging↔working mirror ------------------------------------------------- // (The pre-reshape staging↔working mirror was retired: with staging at
// archive/<party>/staging/<batch>/ and working at archive/<party>/
// stagingMirrorURL builds a URL-safe target path for a transmittal folder // working/<email>/, the project-level pairing no longer maps cleanly.
// name with spaces and parens, mirroring how a real client would encode it. // Tests for the removed behaviour have been deleted.)
func stagingMirrorURL(project, folder string) string {
return "/" + project + "/staging/" + url.PathEscape(folder) + "/"
}
func TestFileAPI_StagingMirror_TRN(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - Foundation Plans"
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("staging mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
}
// Staging side exists with auto-own.
stagingDir := filepath.Join(root, "Proj/staging", folder)
if info, err := os.Stat(stagingDir); err != nil || !info.IsDir() {
t.Fatalf("staging folder not created: err=%v", err)
}
if _, err := os.Stat(filepath.Join(stagingDir, ".zddc")); err != nil {
t.Errorf("staging auto-own .zddc missing: %v", err)
}
// Working mirror exists with auto-own.
workingDir := filepath.Join(root, "Proj/working", folder)
if info, err := os.Stat(workingDir); err != nil || !info.IsDir() {
t.Fatalf("working mirror not created: err=%v", err)
}
mirrorZ, err := os.ReadFile(filepath.Join(workingDir, ".zddc"))
if err != nil {
t.Fatalf("working mirror auto-own .zddc missing: %v", err)
}
if !strings.Contains(string(mirrorZ), "alice@example.com: rwcda") {
t.Errorf("mirror .zddc missing creator grant: %s", mirrorZ)
}
}
func TestFileAPI_StagingMirror_SUB(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
folder := "2026-07-01_vendor-EM-SUB-0017 (RSA) - Review Notes"
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("mkdir: want 201, got %d", rec.Code)
}
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); err != nil {
t.Errorf("SUB-tracked folder should mirror; got err=%v", err)
}
}
func TestFileAPI_StagingMirror_NonTransmittalNameSkipped(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
rec := do(http.MethodPost, "/Proj/staging/scratch/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("mkdir: want 201, got %d", rec.Code)
}
// staging/scratch/ exists.
if _, err := os.Stat(filepath.Join(root, "Proj/staging/scratch")); err != nil {
t.Fatalf("staging/scratch not created: %v", err)
}
// No working/ sibling — name doesn't parse as transmittal.
if _, err := os.Stat(filepath.Join(root, "Proj/working/scratch")); !os.IsNotExist(err) {
t.Errorf("non-transmittal name must NOT mirror; got err=%v", err)
}
}
func TestFileAPI_StagingMirror_MdlTrackingSkipped(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
folder := "2026-06-15_proj-EM-MDL-0001 (IFR) - Master Deliverables List"
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("mkdir: want 201, got %d", rec.Code)
}
// MDL deliverables are tracked in archive/<party>/mdl/, not via the
// working↔staging pairing — no mirror.
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); !os.IsNotExist(err) {
t.Errorf("-MDL- tracking must NOT mirror; got err=%v", err)
}
}
func TestFileAPI_StagingMirror_DeepPathSkipped(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
// mkdir of staging/<name>/sub/ (depth 4) — only depth-3 (immediate
// child of staging/) qualifies for mirroring.
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - x"
if err := os.MkdirAll(filepath.Join(root, "Proj/staging", folder), 0o755); err != nil {
t.Fatal(err)
}
rec := do(http.MethodPost, "/Proj/staging/"+url.PathEscape(folder)+"/sub/", "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
t.Fatalf("deep mkdir: got %d: %s", rec.Code, rec.Body.String())
}
// The transmittal folder did not get a mirror retroactively because
// the mirror only fires on depth-3 mkdirs.
if _, err := os.Stat(filepath.Join(root, "Proj/working", folder)); !os.IsNotExist(err) {
t.Errorf("deep mkdir should not retroactively mirror; got err=%v", err)
}
}
func TestFileAPI_StagingMirror_Idempotent(t *testing.T) {
_, do, root := fileAPITestSetup(t, nil, nil)
// Pre-create the working sibling with a sentinel file so we can detect
// if the mirror code blew it away.
folder := "2026-06-15_proj-EM-TRN-0042 (DFT) - existing"
mirrorDir := filepath.Join(root, "Proj/working", folder)
if err := os.MkdirAll(mirrorDir, 0o755); err != nil {
t.Fatal(err)
}
sentinel := filepath.Join(mirrorDir, "preexisting.md")
if err := os.WriteFile(sentinel, []byte("user content"), 0o644); err != nil {
t.Fatal(err)
}
rec := do(http.MethodPost, stagingMirrorURL("Proj", folder), "alice@example.com", nil, map[string]string{
"X-ZDDC-Op": "mkdir",
})
if rec.Code != http.StatusCreated {
t.Fatalf("mkdir: want 201, got %d", rec.Code)
}
// Sentinel still exists — mirror was idempotent (no-op when sibling
// already present).
if _, err := os.Stat(sentinel); err != nil {
t.Errorf("idempotency: pre-existing content gone: %v", err)
}
}

View file

@ -31,9 +31,10 @@ import (
// cascade defaults; the same `c` (write-once-create) verb that // cascade defaults; the same `c` (write-once-create) verb that
// lets them file canonical submittals lets them establish this // lets them file canonical submittals lets them establish this
// .zddc once. // .zddc once.
// - ActionAdmin on reviewing_root/.zddc + staging_root/.zddc. The // - ActionAdmin on archive/<party>/reviewing/.zddc and
// invoker must already administer those subtrees per the cascade // archive/<party>/staging/.zddc. The invoker must already
// defaults. // administer those subtrees per the cascade defaults (which give
// subtree-admin of the party folder to document_controller).
// //
// Operation: // Operation:
// //
@ -131,23 +132,31 @@ func servePlanReview(cfg config.Config, w http.ResponseWriter, r *http.Request)
// //
// Exposed so accept-transmittal can chain Plan Review in the same // Exposed so accept-transmittal can chain Plan Review in the same
// request without round-tripping through HTTP. // request without round-tripping through HTTP.
//
// Path convention is hardcoded per the layout reshape: workflow
// folders are scaffolded under archive/<party>/{reviewing,staging}/.
// No reviewing_root/staging_root cascade keys are consulted —
// scaffolding always lands inside the same party folder that owns the
// originating received/<tracking>/ submittal.
func executePlanReview(cfg config.Config, r *http.Request, project, party, tracking string, req planReviewRequest) (*planReviewResponse, int, string) { func executePlanReview(cfg config.Config, r *http.Request, project, party, tracking string, req planReviewRequest) (*planReviewResponse, int, string) {
receivedRel := filepath.ToSlash(filepath.Join("archive", party, "received", tracking)) receivedRel := filepath.ToSlash(filepath.Join("archive", party, "received", tracking))
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 + "/"
prCfg := zddc.OnPlanReviewAt(cfg.Root, receivedAbs) // Hardcoded path convention. Every project has exactly one
if prCfg == nil || prCfg.ReviewingRoot == "" || prCfg.StagingRoot == "" { // reviewing/ and one staging/ slot per party at fixed offsets;
return nil, http.StatusConflict, "Conflict — on_plan_review is not configured in the cascade for this subtree" // the composite endpoint scaffolds inside the originating party's
} // slots.
reviewingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.ReviewingRoot, "/"))) reviewingRoot := filepath.Join(cfg.Root, project, "archive", party, "reviewing")
stagingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.StagingRoot, "/"))) stagingRoot := filepath.Join(cfg.Root, project, "archive", party, "staging")
// Pre-flight authorisation. No ACL exception — we use existing // Pre-flight authorisation. No ACL exception — we use existing
// cascade grants: // cascade grants:
// (a) ActionAdmin on reviewing_root and staging_root proves the // (a) ActionAdmin on archive/<party>/reviewing/ and
// invoker is subtree-admin of the workflow roots and can // archive/<party>/staging/ proves the invoker is subtree-
// write the workflow .zddc files. // admin of the workflow roots (inherited from the per-party
// `admins: [document_controller]` in the cascade defaults)
// and can write the workflow .zddc files.
// (b) The invoker has `c` (write-once-create) authority on // (b) The invoker has `c` (write-once-create) authority on
// received/<tracking>/. For the doc_controller this comes // received/<tracking>/. For the doc_controller this comes
// from `worm: [document_controller]` on received/ in the // from `worm: [document_controller]` on received/ in the

View file

@ -200,7 +200,7 @@ func TestPlanReview_Idempotent(t *testing.T) {
} }
// Confirm no duplicate folders snuck in. // Confirm no duplicate folders snuck in.
reviewingRoot := filepath.Join(root, "Project-1", "reviewing") reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
entries, err := os.ReadDir(reviewingRoot) entries, err := os.ReadDir(reviewingRoot)
if err != nil { if err != nil {
t.Fatalf("read %s: %v", reviewingRoot, err) t.Fatalf("read %s: %v", reviewingRoot, err)
@ -247,7 +247,7 @@ func TestPlanReview_ReceivedZddcIsWriteOnce(t *testing.T) {
} }
// reviewing/.zddc reflects the new review_lead. // reviewing/.zddc reflects the new review_lead.
reviewingRoot := filepath.Join(root, "Project-1", "reviewing") reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
entries, err := os.ReadDir(reviewingRoot) entries, err := os.ReadDir(reviewingRoot)
if err != nil { if err != nil {
t.Fatalf("read %s: %v", reviewingRoot, err) t.Fatalf("read %s: %v", reviewingRoot, err)
@ -274,11 +274,12 @@ func TestPlanReview_Forbidden(t *testing.T) {
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
t.Fatalf("status=%d, want 403; body=%s", rec.Code, rec.Body.String()) t.Fatalf("status=%d, want 403; body=%s", rec.Code, rec.Body.String())
} }
if _, err := os.Stat(filepath.Join(root, "Project-1", "reviewing")); err == nil { reviewingRoot := filepath.Join(root, "Project-1", "archive", "Acme", "reviewing")
if _, err := os.Stat(reviewingRoot); err == nil {
// reviewing/ should not have been materialised. The mkdir // reviewing/ should not have been materialised. The mkdir
// happens AFTER the ACL check in the handler, so refusal // happens AFTER the ACL check in the handler, so refusal
// guarantees no state change. // guarantees no state change.
entries, _ := os.ReadDir(filepath.Join(root, "Project-1", "reviewing")) entries, _ := os.ReadDir(reviewingRoot)
if len(entries) > 0 { if len(entries) > 0 {
t.Errorf("reviewing/ created despite 403: %d entries", len(entries)) t.Errorf("reviewing/ created despite 403: %d entries", len(entries))
} }

View file

@ -1515,7 +1515,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp">v0.0.19</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.20-dev · 2026-05-20 20:09:54 · 703449a-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -4219,6 +4219,17 @@ body.is-elevated::after {
const col = colAt(c); const col = colAt(c);
if (!row || !col) return; if (!row || !col) return;
// $-prefixed columns are system-synthesized fields (e.g. the
// `$party` source-party qualifier on project-rollup MDL/RSK
// views). Their value is derived from the row's canonical
// path on read and stripped before any write — editing them
// would have no effect on disk, so suppress entry to edit
// mode entirely. Selection still works for keyboard
// navigation across the cell.
if (typeof col.field === 'string' && col.field.charAt(0) === '$') {
return;
}
const propSchema = propertySchemaFor(col); const propSchema = propertySchemaFor(col);
// Complex-type cells (nested object, generic array, oneOf) // Complex-type cells (nested object, generic array, oneOf)
@ -5170,7 +5181,17 @@ body.is-elevated::after {
// form-mode and never produce drafts here, so drafts only // form-mode and never produce drafts here, so drafts only
// contain primitive / string-array values that are safe to // contain primitive / string-array values that are safe to
// overwrite the corresponding top-level field. // overwrite the corresponding top-level field.
return Object.assign({}, data || {}, drafts || {}); //
// $-prefixed keys are system-synthesised on read (e.g. `$party`
// injected by the server's virtual-view handler on project-
// rollup MDL/RSK rows). They are not part of the row's stored
// YAML and would be rejected by the schema's additionalProperties
// rule. Strip them before sending the write.
const merged = Object.assign({}, data || {}, drafts || {});
for (const k of Object.keys(merged)) {
if (k.charAt(0) === '$') delete merged[k];
}
return merged;
} }
function rowFromState(rowId) { function rowFromState(rowId) {

View file

@ -8,13 +8,19 @@
// doesn't carry: // doesn't carry:
// //
// - SSR rows get `name: <party>` so the table renderer has a column // - SSR rows get `name: <party>` so the table renderer has a column
// to sort on and the form edit pre-fills the party name. // to sort on and the form edit pre-fills the party name. (Identity
// - MDL / RSK rollup rows get `party: <party>` so the rollup table // of an SSR row is the party folder name, so the field is named
// can show which package each row came from. // plainly rather than sigil-prefixed.)
// - MDL / RSK rollup rows get `$party: <party>` so the rollup table
// can show which package each row came from. The `$` sigil marks
// the field as system-synthesised: tables tool renders it read-
// only and the form client strips it before submit, so a user-
// 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 stripped before write-back (SSR via serveFormCreateSSR
// strip; MDL/RSK rollup writes go through the generic serveFormUpdate, // strip; MDL/RSK rollup writes go through the generic serveFormUpdate,
// where the path-derived `party:` is rejected by `additionalProperties: // where the path-derived `$party:` is rejected by `additionalProperties:
// false` in the underlying schema — so the client must strip it on // false` in the underlying schema — so the client must strip it on
// submit, which the tables/form JS already does for path-derived // submit, which the tables/form JS already does for path-derived
// fields). // fields).
@ -79,7 +85,7 @@ func ServeVirtualViewRow(w http.ResponseWriter, r *http.Request, vv zddc.Virtual
case zddc.VirtualViewSSRRow: case zddc.VirtualViewSSRRow:
data["name"] = vv.Party data["name"] = vv.Party
case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow: case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow:
data["party"] = vv.Party data["$party"] = vv.Party
} }
out, err := yaml.Marshal(data) out, err := yaml.Marshal(data)

View file

@ -33,19 +33,20 @@ acl:
# `reset: true` on the role at that level — ancestor definitions above # `reset: true` on the role at that level — ancestor definitions above
# the reset are then excluded. # the reset are then excluded.
# #
# document_controller — the people who file into archive/<party>/ # document_controller — the people who file into
# received/ and issued/ (WORM zones). They get read+write-once- # archive/<party>/received/ and issued/ (WORM zones). They get
# create there (via the worm: lists below) and read/write # read+write-once-create there (via the worm: lists below) and
# elsewhere in a project, plus subtree-admin of working/ and # read/write elsewhere in a project, plus subtree-admin of the
# staging/ so they can stand up new top-level folders and manage # per-party working/ + staging/ + reviewing/ so they can stand up
# user/staging subtrees. They are NOT subtree-admin of archive/, # and manage drafting/transmittal/review folders. They are NOT
# so the WORM constraint still binds them in received/issued. # subtree-admin of archive/<party>/, so the WORM constraint still
# binds them in received/issued.
# #
# project_team — everyone working on a project. Read-only across # project_team — everyone working on a project. Read-only across
# the project. Their own working/<email>/ home and anything they # the project. Their own archive/<party>/working/<email>/ home and
# create under incoming/ get a creator-owned auto-own .zddc # anything they create under incoming/ get a creator-owned auto-
# (rwcda) which wins via deepest-match, so "read-only except # own .zddc (rwcda) which wins via deepest-match, so "read-only
# what I own" falls out of the cascade with no special rule. # except what I own" falls out of the cascade with no special rule.
roles: roles:
document_controller: document_controller:
members: [] members: []
@ -89,17 +90,32 @@ available_tools: [archive, browse, landing]
# #
# ── Canonical project structure ──────────────────────────────────────────── # ── Canonical project structure ────────────────────────────────────────────
# #
# Every ZDDC project lives at a top-level directory. Under it the # Every ZDDC project lives at a top-level directory. Under it
# convention is four canonical folders: archive (formal record), # `archive/` is the ONLY real top-level folder; it contains a folder
# working (in-progress workspace), staging (outbound prep), reviewing # per party. Everything party-scoped (the SSR row, MDL/RSK rollups,
# (Plan-Review-managed draft workspaces). Under archive/<party>/ the # WORM received/issued, the incoming drop zone, and the in-flight
# convention is four more: mdl (deliverables list), incoming (counterparty # lifecycle slots working/staging/reviewing) lives uniformly under
# drop zone), received (immutable submittals), issued (immutable responses). # archive/<party>/.
# #
# All of this is expressed via the recursive paths: schema. None of # Six top-level virtuals sit beside archive/ as resolver views:
# the directories need to exist on disk — the cascade walker resolves #
# behaviour from this declaration, so a fresh project lands on # ssr mdl rsk tables rollups across parties
# usable empty views at every well-known URL. # (with a synthesized $party column)
# working staging browse folder-nav listings of
# reviewing parties with non-empty content in
# 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
# (_/.-prefixed) names; the six virtual aggregator names are rejected
# because the virtual would shadow any physical folder created at
# those URLs (see handler/fileapi.go).
#
# 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 # Operators override any of this by mirroring the structure in an
# on-disk .zddc and changing what they need; on-disk values win. # on-disk .zddc and changing what they need; on-disk values win.
@ -119,15 +135,43 @@ paths:
permissions: permissions:
project_team: r project_team: r
document_controller: rw document_controller: rw
# Plan Review composite endpoint: the doc controller right-clicks
# archive/<party>/received/<tracking>/ in the browse app and gets
# a "Plan Review" item that scaffolds workflow folders under the
# paths below. Both keys required; omitting the block disables
# the menu item for this subtree.
on_plan_review:
reviewing_root: reviewing/
staging_root: staging/
paths: paths:
# ── Top-level virtual aggregators ───────────────────────────
#
# 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
rsk:
default_tool: tables
available_tools: [tables]
virtual: true
working:
default_tool: browse
available_tools: [browse]
virtual: 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 # The doc controller can create party subfolders here
@ -145,12 +189,20 @@ paths:
# to received/issued). That lets them set up the # to received/issued). That lets them set up the
# counterparty's own .zddc afterward. # counterparty's own .zddc afterward.
auto_own: true auto_own: true
# Doc controller is subtree-admin of this party folder —
# full manage authority over the in-flight lifecycle
# slots (working/staging/reviewing) declared below. The
# WORM constraint on received/issued is enforced by the
# cascade's worm: lists, not by admin grants, so they
# still file write-once into those slots.
admins: [document_controller]
# SSR record: the party folder's ssr.yaml carries this # SSR record: the party folder's ssr.yaml carries this
# party's vendor / contract / status data. Scoped by # party's vendor / contract / status data. Scoped by
# filename pattern so the lock on `kind` only applies to # filename pattern so the lock on `kind` only applies to
# ssr.yaml — the mdl/, rsk/, received/ subfolders are # ssr.yaml — the mdl/, rsk/, received/, working/,
# untouched. No filename_format because identity is the # staging/, reviewing/ subfolders are untouched. No
# party folder name, not a composed tracking number. # filename_format because identity is the party folder
# name, not a composed tracking number.
records: records:
"ssr.yaml": "ssr.yaml":
field_defaults: field_defaults:
@ -251,74 +303,47 @@ paths:
issued: issued:
default_tool: archive default_tool: archive
worm: [document_controller] worm: [document_controller]
working: # ── In-flight lifecycle slots (NEW — nested per-party) ────
default_tool: browse #
available_tools: [browse, classifier] # working/staging/reviewing now live inside each party
# working/ auto-owns the first creator + the per-user homes # folder instead of at the project root. The project-
# below. # level <project>/{working,staging,reviewing} virtuals
auto_own: true # (declared above) are folder-nav views over these
drop_target: true # canonical per-party slots.
# Doc controller is subtree-admin of working/ — full create working:
# + manage, including taking over a fenced per-user home if a default_tool: browse
# user leaves. (Scoped here, not at the project root, so the available_tools: [browse, classifier]
# WORM constraint in archive/<party>/received|issued still # working/ auto-owns the first creator + the per-user
# binds them.) # homes below.
admins: [document_controller] auto_own: true
paths: drop_target: true
"*": # per-user home dir paths:
default_tool: browse "*": # per-user home dir, fenced
available_tools: [browse, classifier] default_tool: browse
auto_own: true available_tools: [browse, classifier]
# Per-user home is private by default: the generated auto_own: true
# auto-own .zddc carries inherit:false so ancestor ACL # Per-user home is private by default: the generated
# grants don't reach inside. The user can edit the file # auto-own .zddc carries inherit:false so ancestor ACL
# to grant collaborators access. # grants don't reach inside. The user can edit the file
auto_own_fenced: true # to grant collaborators access.
drop_target: true auto_own_fenced: true
staging: drop_target: true
default_tool: transmittal staging:
available_tools: [transmittal, classifier] default_tool: transmittal
auto_own: true available_tools: [transmittal, classifier]
drop_target: true auto_own: true
# Doc controller is subtree-admin of staging/ too — same drop_target: true
# rationale as working/. reviewing:
admins: [document_controller] default_tool: browse
reviewing: available_tools: [browse]
default_tool: browse # reviewing/ is the doc-controller's draft-workspace
available_tools: [browse] # area inside this party folder. The "Plan Review"
# reviewing/ is the doc-controller's draft-workspace area. The # composite endpoint scaffolds a physical folder here
# "Plan Review" composite endpoint (see on_plan_review at project # for each submittal under review, with a .zddc
# level) scaffolds a physical folder here for each submittal # carrying received_path back to the canonical
# under review, with a .zddc carrying received_path back to the # submittal in received/. Subtree-admin (inherited
# canonical submittal in received/. Subtree-admin so the doc # from the party-level admins:) so the doc
# controller can author per-folder .zddc files (originator ACL, # controller can author per-folder .zddc files
# planned_date). # (originator ACL, planned_date).
auto_own: true auto_own: true
drop_target: true drop_target: true
admins: [document_controller]
# Project-level aggregation tables. All three are virtual: the
# folder doesn't exist on disk; the server synthesizes listings
# by walking archive/*/ at request time. ACL on each synthetic
# row is evaluated against the canonical archive/<party>/ path,
# so party owners can edit their own rows and non-owners see
# them read-only.
ssr:
default_tool: tables
available_tools: [tables]
# SSR aggregates one row per party folder; the row's backing
# file is archive/<party>/ssr.yaml. + Add row in this view
# creates a new party folder.
virtual: true
mdl:
default_tool: tables
available_tools: [tables]
# Project-rollup of every archive/<party>/mdl/ row. Read +
# edit; + Add row is disabled because party affiliation is
# ambiguous here (add at the per-party path instead).
virtual: true
rsk:
default_tool: tables
available_tools: [tables]
# Project-rollup of every archive/<party>/rsk/ row. Same
# semantics as the mdl rollup.
virtual: 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" || seg == "working" || seg == "staging" { if seg == "archive" {
if err := resolveAt(1, seg); err != nil { if err := resolveAt(1, seg); err != nil {
return target, err return target, err
} }
@ -60,7 +60,8 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") { if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
seg := strings.ToLower(parts[3]) seg := strings.ToLower(parts[3])
switch seg { switch seg {
case "mdl", "incoming", "received", "issued": case "mdl", "rsk", "incoming", "received", "issued",
"working", "staging", "reviewing":
if err := resolveAt(3, seg); err != nil { if err := resolveAt(3, seg); err != nil {
return target, err return target, err
} }
@ -70,26 +71,26 @@ 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). For // creating any missing canonical-folder ancestor with MkdirAll(perm).
// freshly-created auto-own ancestors (working/, staging/, or // For freshly-created auto-own ancestors (archive/<party>/, and the per-
// archive/<party>/incoming/), it also writes a creator-owned .zddc using // party lifecycle slots {working,staging,reviewing,incoming}), it also
// principalEmail (skipped if principalEmail is empty). // writes a creator-owned .zddc using principalEmail (skipped if
// principalEmail is empty).
// //
// 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
// casing — so a pre-existing Working/ is reused rather than shadowed by a // casing — so a pre-existing Archive/ is reused rather than shadowed by a
// new working/ sibling. The basename of target is never altered. // new archive/ sibling. The basename of target is never altered.
// //
// Canonical positions, relative to fsRoot: // Canonical positions, relative to fsRoot:
// //
// - <project>/<canonical-root> where <canonical-root> ∈ // - <project>/archive (the only physical project-root canonical;
// {archive, working, staging} // working/staging/reviewing/ssr/mdl/rsk at project root are virtual
// aggregators with no on-disk presence — writes targeting them
// must be rejected by the caller's project-root mkdir guard.)
// - <project>/archive/<party>/<canonical-party> where // - <project>/archive/<party>/<canonical-party> where
// <canonical-party> ∈ {mdl, incoming, received, issued} // <canonical-party> ∈ {mdl, rsk, incoming, received, issued,
// // working, staging, reviewing}
// "reviewing" is intentionally NOT created here — it's a purely virtual
// route. A write that targets a path under <project>/reviewing/ returns
// an error (callers should reject before invoking this helper).
// //
// 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,9 +110,15 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
return target, nil return target, nil
} }
// Reject writes under reviewing/ — virtual route. // Reject writes targeting top-level virtual aggregators —
if len(parts) >= 2 && strings.EqualFold(parts[1], "reviewing") { // <project>/{ssr,mdl,rsk,working,staging,reviewing}/... — these
return target, fmt.Errorf("reviewing/ is virtual and not writable") // resolve through ResolveVirtualView, not as physical paths. A
// caller writing under them bypassed the virtual resolver.
if len(parts) >= 2 {
switch strings.ToLower(parts[1]) {
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1])
}
} }
resolvedSegs := make([]string, len(parts)) resolvedSegs := make([]string, len(parts))
@ -155,21 +162,23 @@ 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 / working / staging. // Depth 1 candidate: archive (only physical project-root canonical).
seg := strings.ToLower(parts[1]) seg := strings.ToLower(parts[1])
if seg == "archive" || seg == "working" || seg == "staging" { if seg == "archive" {
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>): mdl / incoming / // Depth 3 candidate (archive/<party>/<canonical-party>): the eight
// received / issued. Only meaningful when depth 1 is "archive". // physical per-party slots. Only meaningful when depth 1 is
// "archive".
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") { if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
seg := strings.ToLower(parts[3]) seg := strings.ToLower(parts[3])
switch seg { switch seg {
case "mdl", "incoming", "received", "issued": case "mdl", "rsk", "incoming", "received", "issued",
"working", "staging", "reviewing":
if err := resolveAt(3, seg); err != nil { if err := resolveAt(3, seg); err != nil {
return target, err return target, err
} }

View file

@ -9,7 +9,11 @@ import (
func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) { func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
root := t.TempDir() root := t.TempDir()
target := filepath.Join(root, "Proj", "working", "alice@x.com", "notes.md") // Per-user homes now live under archive/<party>/working/<email>/
// after the top-of-project reshape. The depth-3 working slot is
// the canonical-folder position; its auto-own .zddc is unfenced
// and the depth-4 per-user home gets the fenced one.
target := filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com", "notes.md")
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755) resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
if err != nil { if err != nil {
@ -19,8 +23,10 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
t.Errorf("resolved=%q, target=%q (no case variant exists, should be identical)", resolved, target) t.Errorf("resolved=%q, target=%q (no case variant exists, should be identical)", resolved, target)
} }
// working/ is now created with auto-own .zddc. // working/ is now created with auto-own .zddc (unfenced — party
autoZ := filepath.Join(root, "Proj", "working", ".zddc") // admins still cascade through, only the per-user home below is
// fenced).
autoZ := filepath.Join(root, "Proj", "archive", "ACME", "working", ".zddc")
data, err := os.ReadFile(autoZ) data, err := os.ReadFile(autoZ)
if err != nil { if err != nil {
t.Fatalf("auto-own .zddc not written at working/: %v", err) t.Fatalf("auto-own .zddc not written at working/: %v", err)
@ -32,12 +38,15 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
if !strings.Contains(body, "created_by: alice@x.com") { if !strings.Contains(body, "created_by: alice@x.com") {
t.Errorf("created_by missing: %s", body) t.Errorf("created_by missing: %s", body)
} }
if strings.Contains(body, "inherit: false") {
t.Errorf("party working/ .zddc should be UNFENCED so party admins still reach inside; got: %s", body)
}
// alice@x.com/ subfolder gets a FENCED auto-own .zddc — private by // alice@x.com/ subfolder gets a FENCED auto-own .zddc — private by
// default so other users can't read alice's drafts via ancestor // default so other users can't read alice's drafts via ancestor
// cascade. alice can edit the file later to add collaborators. // cascade. alice can edit the file later to add collaborators.
homeZddc := filepath.Join(root, "Proj", "working", "alice@x.com", ".zddc") homeZddc := filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com", ".zddc")
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com")); err != nil { if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com")); err != nil {
t.Errorf("subfolder not created: %v", err) t.Errorf("subfolder not created: %v", err)
} }
homeData, err := os.ReadFile(homeZddc) homeData, err := os.ReadFile(homeZddc)
@ -58,47 +67,52 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) {
// under working/ get the fence. // under working/ get the fence.
func TestEnsureCanonicalAncestors_StagingChildNotFenced(t *testing.T) { func TestEnsureCanonicalAncestors_StagingChildNotFenced(t *testing.T) {
root := t.TempDir() root := t.TempDir()
target := filepath.Join(root, "Proj", "staging", target := filepath.Join(root, "Proj", "archive", "ACME", "staging",
"2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", "doc.pdf") "2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", "doc.pdf")
if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil { if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil {
t.Fatalf("ensure: %v", err) t.Fatalf("ensure: %v", err)
} }
// staging/<folder>/.zddc should not exist (only the parent staging/ // staging/<folder>/.zddc should not exist (only the parent staging/
// gets an auto-own; the date-named child is plain). // gets an auto-own; the date-named child is plain).
childZddc := filepath.Join(root, "Proj", "staging", childZddc := filepath.Join(root, "Proj", "archive", "ACME", "staging",
"2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", ".zddc") "2025-10-31_proj-EM-TRN-0042 (RSA) - Outbound", ".zddc")
if _, err := os.Stat(childZddc); !os.IsNotExist(err) { if _, err := os.Stat(childZddc); !os.IsNotExist(err) {
t.Errorf("staging child should NOT have auto-own .zddc; got err=%v", err) t.Errorf("staging child should NOT have auto-own .zddc; got err=%v", err)
} }
// And the staging/ slot itself gets the unfenced auto-own.
stagingZddc := filepath.Join(root, "Proj", "archive", "ACME", "staging", ".zddc")
if _, err := os.Stat(stagingZddc); err != nil {
t.Errorf("party staging/ auto-own .zddc missing: %v", err)
}
} }
func TestEnsureCanonicalAncestors_CaseFoldReuse(t *testing.T) { func TestEnsureCanonicalAncestors_CaseFoldReuse(t *testing.T) {
root := t.TempDir() root := t.TempDir()
// Pre-create Working/ (PascalCase). // Pre-create Archive/ (PascalCase) — case-fold reuse applies to
if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil { // the canonical project-root slot.
if err := os.MkdirAll(filepath.Join(root, "Proj", "Archive", "ACME", "working"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
target := filepath.Join(root, "Proj", "working", "foo.md") target := filepath.Join(root, "Proj", "archive", "ACME", "working", "foo.md")
resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755) resolved, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
if err != nil { if err != nil {
t.Fatalf("ensure: %v", err) t.Fatalf("ensure: %v", err)
} }
// Resolved path uses on-disk Working/ casing. // Resolved path uses on-disk Archive/ casing.
want := filepath.Join(root, "Proj", "Working", "foo.md") want := filepath.Join(root, "Proj", "Archive", "ACME", "working", "foo.md")
if resolved != want { if resolved != want {
t.Errorf("resolved=%q, want %q", resolved, want) t.Errorf("resolved=%q, want %q", resolved, want)
} }
// No new working/ sibling. // No new lowercase archive/ sibling.
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); !os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(root, "Proj", "archive")); !os.IsNotExist(err) {
t.Errorf("lowercase sibling should not exist; got err=%v", err) t.Errorf("lowercase sibling should not exist; got err=%v", err)
} }
// Working/ already existed before our call — no auto-own .zddc was // Archive/ already existed — no auto-own .zddc was retroactively written.
// retroactively written. if _, err := os.Stat(filepath.Join(root, "Proj", "Archive", ".zddc")); !os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(root, "Proj", "Working", ".zddc")); !os.IsNotExist(err) {
t.Errorf("auto-own .zddc should not be written to a pre-existing folder; got err=%v", err) t.Errorf("auto-own .zddc should not be written to a pre-existing folder; got err=%v", err)
} }
} }
@ -168,30 +182,35 @@ func TestEnsureCanonicalAncestors_WormFoldersNoAutoOwn(t *testing.T) {
func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) { func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) {
root := t.TempDir() root := t.TempDir()
target := filepath.Join(root, "Proj", "working", "anon.md") target := filepath.Join(root, "Proj", "archive", "ACME", "working", "anon.md")
_, err := EnsureCanonicalAncestors(root, target, "" /* no email */, 0o755) _, err := EnsureCanonicalAncestors(root, target, "" /* no email */, 0o755)
if err != nil { if err != nil {
t.Fatalf("ensure: %v", err) t.Fatalf("ensure: %v", err)
} }
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil { if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working")); err != nil {
t.Errorf("working/ not created: %v", err) t.Errorf("working/ not created: %v", err)
} }
if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working", ".zddc")); !os.IsNotExist(err) {
t.Errorf("auto-own .zddc must not be written without principalEmail; got err=%v", err) t.Errorf("auto-own .zddc must not be written without principalEmail; got err=%v", err)
} }
} }
func TestEnsureCanonicalAncestors_RejectsReviewing(t *testing.T) { // Project-root virtual aggregator names are rejected — a write
// targeting <project>/working/<...> bypasses the virtual resolver
// and must not materialise on disk.
func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) {
root := t.TempDir() root := t.TempDir()
target := filepath.Join(root, "Proj", "reviewing", "x.md") for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} {
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755) target := filepath.Join(root, "Proj", slot, "x.md")
if err == nil { _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
t.Errorf("expected error for write under reviewing/, got nil") if err == nil {
} t.Errorf("%s: expected error for write under <project>/%s/, got nil", slot, slot)
if _, err := os.Stat(filepath.Join(root, "Proj", "reviewing")); !os.IsNotExist(err) { }
t.Errorf("reviewing/ must NOT be created on disk; got err=%v", err) if _, err := os.Stat(filepath.Join(root, "Proj", slot)); !os.IsNotExist(err) {
t.Errorf("%s: <project>/%s/ must NOT be created on disk; got err=%v", slot, slot, err)
}
} }
} }

View file

@ -76,18 +76,6 @@ type Role struct {
Reset bool `yaml:"reset,omitempty" json:"reset,omitempty"` Reset bool `yaml:"reset,omitempty" json:"reset,omitempty"`
} }
// OnPlanReviewConfig is the cascade block enabling the doc-controller
// "Plan Review" composite endpoint. ReviewingRoot and StagingRoot are
// paths relative to the master root (e.g. "<project>/reviewing/" or
// "archive/<project>/reviewing/"). Both must be non-empty for the
// feature to enable; either being empty disables Plan Review for this
// subtree (the right-click menu item hides client-side via
// /.profile/access exposure of this config).
type OnPlanReviewConfig struct {
ReviewingRoot string `yaml:"reviewing_root,omitempty" json:"reviewing_root,omitempty"`
StagingRoot string `yaml:"staging_root,omitempty" json:"staging_root,omitempty"`
}
// ConvertMetadata supplies per-project template variables for the // ConvertMetadata supplies per-project template variables for the
// server-side MD→{docx,html,pdf} conversion endpoint. The handler // server-side MD→{docx,html,pdf} conversion endpoint. The handler
// resolves the effective set by walking the .zddc cascade leaf→root // resolves the effective set by walking the .zddc cascade leaf→root
@ -337,12 +325,6 @@ type ZddcFile struct {
PlannedReviewDate string `yaml:"planned_review_date,omitempty" json:"planned_review_date,omitempty"` PlannedReviewDate string `yaml:"planned_review_date,omitempty" json:"planned_review_date,omitempty"`
PlannedResponseDate string `yaml:"planned_response_date,omitempty" json:"planned_response_date,omitempty"` PlannedResponseDate string `yaml:"planned_response_date,omitempty" json:"planned_response_date,omitempty"`
// OnPlanReview is the cascade-declared configuration for the
// "Plan Review" composite endpoint. Empty (nil) means Plan Review
// is not enabled at this subtree — the browse client hides the
// menu item. Set in an ancestor .zddc to enable.
OnPlanReview *OnPlanReviewConfig `yaml:"on_plan_review,omitempty" json:"on_plan_review,omitempty"`
// FieldCodes declares the vocabulary of "field codes" used as // FieldCodes declares the vocabulary of "field codes" used as
// components of tracking numbers and as constrained body fields // components of tracking numbers and as constrained body fields
// on record YAMLs (mdl rows, rsk rows, ssr rows). The map key is // on record YAMLs (mdl rows, rsk rows, ssr rows). The map key is

View file

@ -226,58 +226,45 @@ 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 "archive", "working", "staging", "reviewing",
// "incoming", "received", "issued", "mdl" — or "" if the path is not // "incoming", "received", "issued", "mdl", "rsk" — or "" if the path
// at a canonical-folder slot. // is not at a canonical-folder slot.
// //
// Detection is structural against the canonical project layout declared // Detection is structural against the canonical project layout declared
// in defaults.zddc.yaml: top-level <project>/{archive,working,staging, // in defaults.zddc.yaml:
// reviewing} and the second-level archive/<party>/{mdl,incoming, //
// received,issued}. Operators don't rename these slots (the cascade // - top-level <project>/archive is the only physical project-root
// keys them by literal name); a custom layout that does is on its own. // canonical slot (the working/staging/reviewing/ssr/mdl/rsk URLs
// at project root are virtual aggregators, not on-disk folders).
// - third-level archive/<party>/{mdl,rsk,incoming,received,issued,
// working,staging,reviewing} are the physical per-party canonical
// slots.
//
// Operators don't rename these slots (the cascade keys them by
// literal name); a custom layout that does is on its own.
// //
// Used by the browse SPA to scope-gate context-menu actions (Accept, // Used by the browse SPA to scope-gate context-menu actions (Accept,
// Stage/Unstage, Create Transmittal folder) without re-implementing the // Stage/Unstage, Create Transmittal folder) without re-implementing the
// cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder 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> // <project>/<folder> — only archive/ is physical at project root.
if len(segs) == 2 { if len(segs) == 2 {
switch segs[1] { if segs[1] == "archive" {
case "archive", "working", "staging", "reviewing": return "archive"
return segs[1]
} }
return "" return ""
} }
// <project>/archive/<party>/<folder> // <project>/archive/<party>/<folder>
if len(segs) == 4 && segs[1] == "archive" { if len(segs) == 4 && segs[1] == "archive" {
switch segs[3] { switch segs[3] {
case "incoming", "received", "issued", "mdl": case "incoming", "received", "issued", "mdl", "rsk",
"working", "staging", "reviewing":
return segs[3] return segs[3]
} }
} }
return "" return ""
} }
// OnPlanReviewAt returns the cascade-resolved Plan Review configuration
// for dirPath, or nil if no level (on-disk, virtual via Paths, or
// embedded) declares one. Walks chain.Levels from leaf toward root,
// returning the first non-nil OnPlanReview. The block has to be present
// somewhere in the ancestry for the "Plan Review" menu item to surface
// in the browse client and for the composite endpoint to know where to
// scaffold workflow folders.
func OnPlanReviewAt(fsRoot, dirPath string) *OnPlanReviewConfig {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return nil
}
for i := len(chain.Levels) - 1; i >= 0; i-- {
if cfg := chain.Levels[i].OnPlanReview; cfg != nil {
return cfg
}
}
return chain.Embedded.OnPlanReview
}
// leafLevel returns the deepest (most-specific) ZddcFile in chain. // leafLevel returns the deepest (most-specific) ZddcFile in chain.
// Caller's responsibility to check len(chain.Levels) > 0 — but // Caller's responsibility to check len(chain.Levels) > 0 — but
// returns ZddcFile{} on empty for ergonomic chaining. // returns ZddcFile{} on empty for ergonomic chaining.
@ -303,7 +290,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
return false return false
} }
if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" || if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" ||
zf.PlannedResponseDate != "" || zf.OnPlanReview != nil { zf.PlannedResponseDate != "" {
return false return false
} }
if len(zf.AvailableTools) > 0 { if len(zf.AvailableTools) > 0 {

View file

@ -9,6 +9,11 @@ import (
// TestDefaultToolAt_FromEmbeddedConvention — the canonical default- // TestDefaultToolAt_FromEmbeddedConvention — the canonical default-
// tool rules in defaults.zddc.yaml should resolve correctly for the // tool rules in defaults.zddc.yaml should resolve correctly for the
// well-known paths without any on-disk .zddc. // well-known paths without any on-disk .zddc.
//
// Layout reshape: lifecycle slots (working/staging/reviewing) now
// live under archive/<party>/. The project-level
// <project>/{working,staging,reviewing} URLs are virtual folder-nav
// aggregators (default_tool=browse).
func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) { func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
@ -18,12 +23,20 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
}{ }{
{filepath.Join(root, "Project-X", "archive"), "archive"}, {filepath.Join(root, "Project-X", "archive"), "archive"},
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "tables"}, {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "tables"},
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), "tables"},
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"}, {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"},
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"}, {filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"},
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"}, {filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"},
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), "browse"},
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), "browse"},
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), "transmittal"},
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), "browse"},
// Project-level virtual aggregators.
{filepath.Join(root, "Project-X", "ssr"), "tables"},
{filepath.Join(root, "Project-X", "mdl"), "tables"},
{filepath.Join(root, "Project-X", "rsk"), "tables"},
{filepath.Join(root, "Project-X", "working"), "browse"}, {filepath.Join(root, "Project-X", "working"), "browse"},
{filepath.Join(root, "Project-X", "working", "alice@example.com"), "browse"}, {filepath.Join(root, "Project-X", "staging"), "browse"},
{filepath.Join(root, "Project-X", "staging"), "transmittal"},
{filepath.Join(root, "Project-X", "reviewing"), "browse"}, {filepath.Join(root, "Project-X", "reviewing"), "browse"},
} }
for _, tc := range cases { for _, tc := range cases {
@ -45,7 +58,7 @@ func TestDirToolAt(t *testing.T) {
// whose default_tool (no-slash form) is something else. // whose default_tool (no-slash form) is something else.
for _, p := range []string{ for _, p := range []string{
filepath.Join(root, "Project-X"), filepath.Join(root, "Project-X"),
filepath.Join(root, "Project-X", "working"), filepath.Join(root, "Project-X", "archive", "Acme", "working"),
filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), filepath.Join(root, "Project-X", "archive", "Acme", "mdl"),
filepath.Join(root, "Project-X", "random", "deep", "folder"), filepath.Join(root, "Project-X", "random", "deep", "folder"),
} { } {
@ -73,8 +86,14 @@ func TestDirToolAt(t *testing.T) {
// TestCanonicalFolderAt — structural detection of the canonical // TestCanonicalFolderAt — structural detection of the canonical
// project-layout slots that the browse SPA scope-gates context-menu // project-layout slots that the browse SPA scope-gates context-menu
// actions against. Top-level <project>/<folder> and second-level // actions against.
// <project>/archive/<party>/<folder>; everything else returns "". //
// After the layout reshape:
// - <project>/archive is the only depth-2 canonical
// - <project>/archive/<party>/<slot> covers the eight per-party
// physical slots (incoming, received, issued, mdl, rsk, working,
// staging, reviewing)
// - everything else returns ""
func TestCanonicalFolderAt(t *testing.T) { func TestCanonicalFolderAt(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
@ -83,16 +102,21 @@ func TestCanonicalFolderAt(t *testing.T) {
want string want string
}{ }{
{filepath.Join(root, "Project-X", "archive"), "archive"}, {filepath.Join(root, "Project-X", "archive"), "archive"},
{filepath.Join(root, "Project-X", "working"), "working"},
{filepath.Join(root, "Project-X", "staging"), "staging"},
{filepath.Join(root, "Project-X", "reviewing"), "reviewing"},
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "incoming"}, {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "incoming"},
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "received"}, {filepath.Join(root, "Project-X", "archive", "Acme", "received"), "received"},
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "issued"}, {filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "issued"},
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "mdl"}, {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "mdl"},
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), "rsk"},
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), "working"},
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), "staging"},
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), "reviewing"},
// Project-root virtuals are NOT canonical-folder slots.
{filepath.Join(root, "Project-X", "working"), ""},
{filepath.Join(root, "Project-X", "staging"), ""},
{filepath.Join(root, "Project-X", "reviewing"), ""},
{root, ""}, {root, ""},
{filepath.Join(root, "Project-X"), ""}, {filepath.Join(root, "Project-X"), ""},
{filepath.Join(root, "Project-X", "working", "alice@example.com"), ""}, {filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), ""},
{filepath.Join(root, "Project-X", "archive", "Acme"), ""}, {filepath.Join(root, "Project-X", "archive", "Acme"), ""},
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming", "2026-05-15_Acme-0042 (RFI) - Foundation"), ""}, {filepath.Join(root, "Project-X", "archive", "Acme", "incoming", "2026-05-15_Acme-0042 (RFI) - Foundation"), ""},
{filepath.Join(root, "Project-X", "random", "dir"), ""}, {filepath.Join(root, "Project-X", "random", "dir"), ""},
@ -107,7 +131,8 @@ func TestCanonicalFolderAt(t *testing.T) {
} }
// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for // TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for
// working/incoming/staging (per the convention) and false elsewhere. // the per-party lifecycle slots (working/staging/reviewing/incoming)
// and false for received/issued/mdl/rsk.
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) { func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
@ -115,13 +140,15 @@ func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
path string path string
want bool want bool
}{ }{
{filepath.Join(root, "Project-X", "working"), true}, {filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
{filepath.Join(root, "Project-X", "working", "alice@example.com"), true}, {filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true},
{filepath.Join(root, "Project-X", "staging"), true}, {filepath.Join(root, "Project-X", "archive", "Acme", "staging"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true}, {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false}, {filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), false}, {filepath.Join(root, "Project-X", "archive", "Acme", "issued"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false}, {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), false},
} }
for _, tc := range cases { for _, tc := range cases {
got := AutoOwnAt(root, tc.path) got := AutoOwnAt(root, tc.path)
@ -132,9 +159,9 @@ func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
} }
} }
// TestVirtualAt_FromEmbeddedConvention — mdl/ is declared virtual; // TestVirtualAt_FromEmbeddedConvention — mdl/rsk under a party are
// everything else (including reviewing/, which is now Plan-Review- // declared virtual, and the six project-level aggregators are virtual.
// managed with physical workflow folders) materialises on disk. // Other canonical slots materialise on disk.
func TestVirtualAt_FromEmbeddedConvention(t *testing.T) { func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
@ -143,11 +170,19 @@ func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
want bool want bool
}{ }{
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true}, {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true},
{filepath.Join(root, "Project-X", "reviewing"), false}, {filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), true},
{filepath.Join(root, "Project-X", "working"), false}, {filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), false},
{filepath.Join(root, "Project-X", "staging"), false}, {filepath.Join(root, "Project-X", "archive", "Acme", "working"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false}, {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false}, {filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
// Project-level aggregators.
{filepath.Join(root, "Project-X", "ssr"), true},
{filepath.Join(root, "Project-X", "mdl"), true},
{filepath.Join(root, "Project-X", "rsk"), true},
{filepath.Join(root, "Project-X", "working"), true},
{filepath.Join(root, "Project-X", "staging"), true},
{filepath.Join(root, "Project-X", "reviewing"), true},
} }
for _, tc := range cases { for _, tc := range cases {
got := VirtualAt(root, tc.path) got := VirtualAt(root, tc.path)
@ -170,8 +205,11 @@ func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) {
}{ }{
{filepath.Join(root, "Project-X", "archive"), true}, {filepath.Join(root, "Project-X", "archive"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true}, {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
// Project-root aggregators are also declared.
{filepath.Join(root, "Project-X", "working"), true}, {filepath.Join(root, "Project-X", "working"), true},
{filepath.Join(root, "Project-X", "reviewing"), true}, {filepath.Join(root, "Project-X", "reviewing"), true},
{filepath.Join(root, "Project-X", "ssr"), true},
{filepath.Join(root, "Project-X", "junk"), false}, // not in convention {filepath.Join(root, "Project-X", "junk"), false}, // not in convention
} }
for _, tc := range cases { for _, tc := range cases {
@ -183,17 +221,17 @@ func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) {
} }
} }
// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project // TestChildrenDeclaredAt_FromEmbeddedConvention — at a project root
// root, the canonical children should be enumerated: the four // the cascade declares archive/ plus the six top-level virtual
// physical folders (archive, working, staging, reviewing) plus the // aggregator slots (ssr, mdl, rsk, working, staging, reviewing).
// three project-level virtual aggregator slots (ssr, mdl, rsk).
func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) { func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X")) got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X"))
want := map[string]bool{ want := map[string]bool{
"archive": true, "working": true, "staging": true, "reviewing": true, "archive": true,
"ssr": true, "mdl": true, "rsk": true, "ssr": true, "mdl": true, "rsk": true,
"working": true, "staging": true, "reviewing": true,
} }
if len(got) != len(want) { if len(got) != len(want) {
t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want) t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want)
@ -211,19 +249,19 @@ func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) { func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "Special", "working"), 0o755); err != nil { if err := os.MkdirAll(filepath.Join(root, "Special", "archive", "Acme", "working"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Operator declares that Special/working uses classifier // Operator declares that Special/archive/Acme/working uses
// instead of the embedded-default browse. // classifier instead of the embedded-default browse.
writeZddc(t, filepath.Join(root, "Special", "working"), writeZddc(t, filepath.Join(root, "Special", "archive", "Acme", "working"),
"default_tool: classifier\n") "default_tool: classifier\n")
if got := DefaultToolAt(root, filepath.Join(root, "Special", "working")); got != "classifier" { if got := DefaultToolAt(root, filepath.Join(root, "Special", "archive", "Acme", "working")); got != "classifier" {
t.Errorf("operator override should set default_tool=classifier, got %q", got) t.Errorf("operator override should set default_tool=classifier, got %q", got)
} }
// Default still applies at other projects. // Default still applies at other projects.
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "browse" { if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "archive", "Acme", "working")); got != "browse" {
t.Errorf("default convention should hold at unchanged paths, got %q", got) t.Errorf("default convention should hold at unchanged paths, got %q", got)
} }
} }
@ -235,8 +273,9 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) { func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
// Deep path under working/ — not explicitly mentioned in paths:. // Deep path under archive/<party>/working/ — not explicitly
deep := filepath.Join(root, "Project-X", "working", "alice@example.com", "notes", "sub", "deep") // mentioned in paths:.
deep := filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes", "sub", "deep")
if got := DefaultToolAt(root, deep); got != "browse" { if got := DefaultToolAt(root, deep); got != "browse" {
t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)", t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)",
deep[len(root):], got) deep[len(root):], got)
@ -248,7 +287,7 @@ func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
func TestAutoOwnAt_DescendantCanDisable(t *testing.T) { func TestAutoOwnAt_DescendantCanDisable(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
deepDir := filepath.Join(root, "Project-X", "working", "alice@example.com") deepDir := filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com")
if err := os.MkdirAll(deepDir, 0o755); err != nil { if err := os.MkdirAll(deepDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -257,7 +296,7 @@ func TestAutoOwnAt_DescendantCanDisable(t *testing.T) {
t.Errorf("AutoOwnAt(%q) = %v, want false (descendant override)", deepDir, got) t.Errorf("AutoOwnAt(%q) = %v, want false (descendant override)", deepDir, got)
} }
// Ancestor still has it true. // Ancestor still has it true.
ancestor := filepath.Join(root, "Project-X", "working") ancestor := filepath.Join(root, "Project-X", "archive", "Acme", "working")
if got := AutoOwnAt(root, ancestor); got != true { if got := AutoOwnAt(root, ancestor); got != true {
t.Errorf("AutoOwnAt(%q) = %v, want true (ancestor untouched)", ancestor, got) t.Errorf("AutoOwnAt(%q) = %v, want true (ancestor untouched)", ancestor, got)
} }
@ -275,7 +314,7 @@ func TestInheritFalse_BlocksEmbeddedDefaults(t *testing.T) {
if IsDeclaredPath(root, filepath.Join(root, "Project-X", "archive")) { if IsDeclaredPath(root, filepath.Join(root, "Project-X", "archive")) {
t.Errorf("with inherit:false at root, archive should not be a declared path") t.Errorf("with inherit:false at root, archive should not be a declared path")
} }
if DefaultToolAt(root, filepath.Join(root, "Project-X", "working")) != "" { if DefaultToolAt(root, filepath.Join(root, "Project-X", "archive", "Acme", "working")) != "" {
t.Errorf("with inherit:false at root, default_tool should be empty for working") t.Errorf("with inherit:false at root, default_tool should be empty for working")
} }
} }

View file

@ -11,8 +11,13 @@ import (
// - rw at the project level (read + overwrite-existing), but NOT c // - rw at the project level (read + overwrite-existing), but NOT c
// (so it can't make arbitrary folders) // (so it can't make arbitrary folders)
// - rwc at archive/ (can create party subfolders) // - rwc at archive/ (can create party subfolders)
// - subtree-admin at working/ and staging/ (full create + manage) // - subtree-admin at archive/<party>/ (full create + manage; lifecycle
// slots under the party inherit the admin grant)
// - inside received/issued (WORM): masked to r + worm-restored c // - inside received/issued (WORM): masked to r + worm-restored c
//
// Layout reshape: working/staging/reviewing moved from project root
// into archive/<party>/, so the subtree-admin scope likewise moved
// from project-level "working/staging/" to the per-party folder.
func TestStandardRoles_DocControllerScopedCreate(t *testing.T) { func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
resetCache() resetCache()
root := t.TempDir() root := t.TempDir()
@ -56,21 +61,30 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "rc") mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "rc")
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "rc") mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "rc")
// Subtree-admin at working/ and staging/ (via admins: [document_controller] // Subtree-admin at archive/<party>/ (the embedded cascade
// in the embedded cascade — role-aware now). // declares admins: [document_controller] on the party "*" entry,
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working"), Principal{Email: dc, Elevated: true}) { // so working/staging/reviewing inside the party inherit it).
t.Errorf("doc controller should be subtree-admin of working/") if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should be subtree-admin of archive/<party>/")
} }
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "staging"), Principal{Email: dc, Elevated: true}) { if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should be subtree-admin of staging/") t.Errorf("doc controller should be subtree-admin of archive/<party>/working/")
} }
// NOT subtree-admin of archive/ (so WORM still binds them there). if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "staging"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should be subtree-admin of archive/<party>/staging/")
}
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "reviewing"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should be subtree-admin of archive/<party>/reviewing/")
}
// NOT subtree-admin of archive/ (so WORM still binds them at the
// received/issued slots below).
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: dc, Elevated: true}) { if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller should NOT be subtree-admin of archive/") t.Errorf("doc controller should NOT be subtree-admin of archive/ (only of each party folder)")
} }
// Subtree-admin reaches inside a fenced per-user working home. // Subtree-admin reaches inside a fenced per-user working home
if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) { // under the party's working slot.
t.Errorf("doc controller (subtree-admin of working/) should reach inside a fenced user home") if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) {
t.Errorf("doc controller (subtree-admin of party) should reach inside a fenced user home")
} }
} }
@ -86,9 +100,9 @@ func TestStandardRoles_ProjectTeamReadOnlyExceptOwned(t *testing.T) {
members: ["*@example.com"] members: ["*@example.com"]
`) `)
// Simulate the auto-own .zddc the file API would write at // Simulate the auto-own .zddc the file API would write at
// working/alice@example.com/ (fenced via acl.inherit:false, // archive/Acme/working/alice@example.com/ (fenced via
// creator-owned). // acl.inherit:false, creator-owned).
homeDir := filepath.Join(root, "Proj", "working", "alice@example.com") homeDir := filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com")
if err := os.MkdirAll(homeDir, 0o755); err != nil { if err := os.MkdirAll(homeDir, 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View file

@ -9,11 +9,11 @@ import (
) )
// Virtual `received/` window — the doc-controller's Plan Review composite // Virtual `received/` window — the doc-controller's Plan Review composite
// endpoint scaffolds physical folders under <reviewing_root> and // endpoint scaffolds physical folders under archive/<party>/reviewing/ and
// <staging_root>, each carrying a .zddc whose `received_path:` points back // archive/<party>/staging/, each carrying a .zddc whose `received_path:`
// at the canonical archive/<party>/received/<tracking>/. When a workflow // points back at the canonical archive/<party>/received/<tracking>/. When
// folder is listed, the server injects a synthetic `received/` child that // a workflow folder is listed, the server injects a synthetic `received/`
// shows the canonical submittal's contents in context. // child that shows the canonical submittal's contents in context.
// //
// Three behaviours rely on this: // Three behaviours rely on this:
// //
@ -51,7 +51,7 @@ func WorkflowReceivedPath(dirPath string) string {
type VirtualReceivedResolution struct { type VirtualReceivedResolution struct {
Resolved bool Resolved bool
WorkflowAbs string // absolute path of the workflow folder WorkflowAbs string // absolute path of the workflow folder
WorkflowURL string // server-relative URL of the workflow folder, slash-terminated (e.g. "/Project/reviewing/2026-05-30_X (TBD) - …/") WorkflowURL string // server-relative URL of the workflow folder, slash-terminated (e.g. "/Project/archive/Acme/reviewing/2026-05-30_X (TBD) - …/")
ReceivedAbs string // absolute path of the canonical received target (or canonical+suffix when the URL drills into a file) ReceivedAbs string // absolute path of the canonical received target (or canonical+suffix when the URL drills into a file)
ReceivedURL string // server-relative URL of the canonical received target ReceivedURL string // server-relative URL of the canonical received target
SuffixURL string // URL suffix after the `/received/` segment, slash-prefixed when non-empty (e.g. "" or "Acme-0042_A (RFI) - Foundation.pdf") SuffixURL string // URL suffix after the `/received/` segment, slash-prefixed when non-empty (e.g. "" or "Acme-0042_A (RFI) - Foundation.pdf")

View file

@ -9,15 +9,36 @@ import (
"strings" "strings"
) )
// Virtual project-level table views — SSR, MDL rollup, RSK rollup. // Virtual project-level views.
// //
// All three are declared `virtual: true` in defaults.zddc.yaml under // Six aggregators live at <project>/, all sibling to the only real
// `<project>/{ssr,mdl,rsk}`. The folder does not exist on disk: the // top-level directory archive/. None of them materialise on disk; the
// server synthesizes listings by walking archive/*/ at request time // server synthesises listings by walking archive/*/ at request time
// and rewrites file reads/writes back to canonical paths inside the // and (for the tables rollups) rewrites file reads/writes back to
// per-party folders. ACL on each synthetic row is evaluated against // canonical paths inside the per-party folders.
// the canonical `<project>/archive/<party>/` chain, so party owners //
// can edit their own rows and non-owners see them read-only. // Two aggregation shapes:
//
// 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 // URL conventions
// //
@ -34,6 +55,10 @@ import (
// //
// /<project>/rsk/ → analogous // /<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 // Modeled on virtualreceived.go: one resolver produces canonical
// paths; every caller (listing builder, file API rewrite, form // paths; every caller (listing builder, file API rewrite, form
// recognizer) reads its policy chain from the canonical path. // recognizer) reads its policy chain from the canonical path.
@ -52,6 +77,13 @@ const (
VirtualViewRSKRoot VirtualViewRSKRoot
VirtualViewRSKSpec VirtualViewRSKSpec
VirtualViewRSKRow 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 // IsRowKind reports whether k targets a per-party row file (true for
@ -77,15 +109,27 @@ func (k VirtualViewKind) IsSpecKind() bool {
// virtual view. // virtual view.
func (k VirtualViewKind) IsRootKind() bool { func (k VirtualViewKind) IsRootKind() bool {
switch k { switch k {
case VirtualViewSSRRoot, VirtualViewMDLRoot, VirtualViewRSKRoot: 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 true
} }
return false return false
} }
// VirtualViewResolution captures the result of mapping a URL onto // VirtualViewResolution captures the result of mapping a URL onto
// one of the project-level virtual table views. All fields are // one of the project-level virtual views. All fields are populated
// populated only when Resolved is true. // only when Resolved is true.
type VirtualViewResolution struct { type VirtualViewResolution struct {
Resolved bool Resolved bool
Kind VirtualViewKind Kind VirtualViewKind
@ -94,7 +138,7 @@ type VirtualViewResolution struct {
ProjectURL string // "/<project>/" ProjectURL string // "/<project>/"
ProjectAbs string // <fsRoot>/<project> ProjectAbs string // <fsRoot>/<project>
Slot string // "ssr", "mdl", or "rsk" Slot string // "ssr", "mdl", "rsk", "working", "staging", "reviewing"
SlotURL string // "/<project>/<slot>/" SlotURL string // "/<project>/<slot>/"
// Populated for VirtualView*Spec kinds: "table.yaml" or "form.yaml". // Populated for VirtualView*Spec kinds: "table.yaml" or "form.yaml".
@ -107,12 +151,18 @@ type VirtualViewResolution struct {
CanonicalURL string // /<project>/archive/<party>/... CanonicalURL string // /<project>/archive/<party>/...
SchemaAbs string // SSR only — <party>/ssr.form.yaml (may not exist; falls back to embedded) 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" 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 // virtualViewRE matches /<project>/<slot>[/<rest>] where slot is one
// of the canonical virtual view names. Capture 1 = project, capture // of the canonical virtual view names. Capture 1 = project, capture
// 2 = slot, capture 3 = rest (may be empty). // 2 = slot, capture 3 = rest (may be empty).
var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk)(?:/(.*))?$`) var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk|working|staging|reviewing)(?:/(.*))?$`)
// partyNameRE matches the SSR schema's `name` pattern. Same regex // partyNameRE matches the SSR schema's `name` pattern. Same regex
// used at row-resolution time so URLs with invalid party tokens fail // used at row-resolution time so URLs with invalid party tokens fail
@ -126,9 +176,47 @@ func ValidPartyName(s string) bool {
return partyNameRE.MatchString(s) return partyNameRE.MatchString(s)
} }
// IsFolderNavSlot reports whether slot is one of the folder-nav
// lifecycle slots (working, staging, reviewing).
func IsFolderNavSlot(slot string) bool {
switch slot {
case "working", "staging", "reviewing":
return true
}
return false
}
// planReviewURLRE matches /<project>/archive/<party>/received/<tracking>/
// — the only URL shape Plan Review accepts. Trailing slash optional.
var planReviewURLRE = regexp.MustCompile(`^/[^/]+/archive/[^/]+/received/[^/]+/?$`)
// IsPlanReviewURL reports whether urlPath is a directory URL eligible
// for the Plan Review composite endpoint — i.e. it points at the
// canonical received/<tracking>/ folder under archive/<party>/. Used
// to surface X-ZDDC-On-Plan-Review on directory responses so the
// browse client can show/hide the right-click menu item.
//
// 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 {
return planReviewURLRE.MatchString(urlPath)
}
// IsRowSlot reports whether slot is one of the tables-rollup slots
// (ssr, mdl, rsk).
func IsRowSlot(slot string) bool {
switch slot {
case "ssr", "mdl", "rsk":
return true
}
return false
}
// ResolveVirtualView inspects urlPath and returns a populated // ResolveVirtualView inspects urlPath and returns a populated
// resolution iff the URL targets one of the project-level virtual // resolution iff the URL targets one of the project-level virtual
// views (ssr/, mdl/, rsk/). On a non-match, Resolved=false. // views (ssr/, mdl/, rsk/, working/, staging/, reviewing/).
// Resolved=false on non-match.
// //
// The resolver does NOT check that the project / party / row file // The resolver does NOT check that the project / party / row file
// actually exist on disk — that's the caller's job (handlers use // actually exist on disk — that's the caller's job (handlers use
@ -165,18 +253,48 @@ func ResolveVirtualView(fsRoot, urlPath string) VirtualViewResolution {
out.SlotURL = "/" + project + "/" + slot + "/" out.SlotURL = "/" + project + "/" + slot + "/"
if rest == "" { if rest == "" {
switch slot { if IsFolderNavSlot(slot) {
case "ssr": out.Kind = VirtualViewFolderNavRoot
out.Kind = VirtualViewSSRRoot } else {
case "mdl": switch slot {
out.Kind = VirtualViewMDLRoot case "ssr":
case "rsk": out.Kind = VirtualViewSSRRoot
out.Kind = VirtualViewRSKRoot case "mdl":
out.Kind = VirtualViewMDLRoot
case "rsk":
out.Kind = VirtualViewRSKRoot
}
} }
out.Resolved = true out.Resolved = true
return out 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" { if rest == "table.yaml" || rest == "form.yaml" {
switch slot { switch slot {
case "ssr": case "ssr":
@ -383,3 +501,52 @@ 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

@ -121,13 +121,12 @@ func TestResolveVirtualView_NonMatches(t *testing.T) {
"/", "/",
"/Project", "/Project",
"/Project/", "/Project/",
"/Project/working",
"/Project/archive/Acme/mdl", "/Project/archive/Acme/mdl",
"/Project/ssr/invalid__name__double.yaml", // double-double underscore is rejected "/Project/ssr/invalid__name__double.yaml", // double-double underscore is rejected
"/Project/mdl/__leading.yaml", // empty party "/Project/mdl/__leading.yaml", // empty party
"/Project/mdl/party__.yaml", // empty rowBase "/Project/mdl/party__.yaml", // empty rowBase
"/Project/ssr/.hidden.yaml", // dotfile party name "/Project/ssr/.hidden.yaml", // dotfile party name
"/Project/ssr/0330C1.yaml/sub", // sub-path under row file "/Project/ssr/0330C1.yaml/sub", // sub-path under row file
"/Project/notaslot/table.yaml", "/Project/notaslot/table.yaml",
} }
for _, url := range cases { for _, url := range cases {
@ -138,6 +137,158 @@ func TestResolveVirtualView_NonMatches(t *testing.T) {
} }
} }
// 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) {
cases := []struct {
url string
want bool
}{
{"/Project/archive/Acme/received/Acme-0042", true},
{"/Project/archive/Acme/received/Acme-0042/", true},
{"/Project/archive/Acme/received", false},
{"/Project/archive/Acme/received/", false},
{"/Project/archive/Acme/received/Acme-0042/file.pdf", false},
{"/Project/archive/Acme/issued/Acme-0042/", false},
{"/Project/archive/Acme", false},
{"/Project/archive", false},
{"/Project", false},
{"/", false},
{"", false},
}
for _, tc := range cases {
if got := IsPlanReviewURL(tc.url); got != tc.want {
t.Errorf("IsPlanReviewURL(%q) = %v, want %v", tc.url, got, tc.want)
}
}
}
func TestIsSSRCreateURL(t *testing.T) { func TestIsSSRCreateURL(t *testing.T) {
cases := []struct { cases := []struct {
url string url string

View file

@ -100,9 +100,6 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.PlannedResponseDate != "" { if top.PlannedResponseDate != "" {
out.PlannedResponseDate = top.PlannedResponseDate out.PlannedResponseDate = top.PlannedResponseDate
} }
if top.OnPlanReview != nil {
out.OnPlanReview = top.OnPlanReview
}
out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools) out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools)
out.Admins = mergeStringSlice(out.Admins, top.Admins) out.Admins = mergeStringSlice(out.Admins, top.Admins)