diff --git a/AGENTS.md b/AGENTS.md index 0d17c61..94a4940 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,7 +287,7 @@ The build enforces lockstep mechanically (one command bumps all 8). The rules be No install script. Two paths: - **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//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 `.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//staging/`, `browse` at `archive//{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive//incoming/`, `tables` at `archive//{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///`). 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 `.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: 1. Drop a real `.html` file at the path → static handler serves it (highest priority). @@ -491,7 +491,7 @@ roles: - '*@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//` 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`): @@ -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. -**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//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//{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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e78f7fa..1ac8f0b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 -There are **no hardcoded folder names** — the canonical project structure (`archive/`, `working/`, `staging/`, `reviewing/`; `archive//{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//{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working//, staging//, reviewing//}`. Six sibling top-level URLs are **virtual aggregators**, never on disk: + +- **Row rollups** (tables tool, `default_tool: tables`) — `/ssr`, `/mdl`, `/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`) — `/working`, `/staging`, `/reviewing`. List the parties whose `archive///` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `//[/]` 302-redirect to the canonical `/archive//[/]`. 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 `/archive//{reviewing,staging}//`, 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 `//archive//received//` 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: @@ -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 | | `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: `/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor plugin), `tables` at `archive//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: `/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `` serves `default_tool` (the specialized app — `archive` at `archive/`, `transmittal` at `archive//staging/`, `browse` at `archive//{working,reviewing}/`, `tables` at `archive//{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`. @@ -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//{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//` 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//` 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//working//` 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) diff --git a/CLAUDE.md b/CLAUDE.md index e85fd50..1421b6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `.form.yaml` file in the tree becomes an editable form at `/.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 ` validated against self-issued tokens at `/.zddc.d/tokens/` 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 ` 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. -- **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 `-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`_v.html`) are immutable; partial-version pins (`_v.html`, `_v.html`) and channel mirrors (`_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v_` per-version binaries plus channel/partial-version symlinks plus `zddc-server_.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 `` — `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor), `classifier` under `incoming/`, `tables` at `archive//mdl`, `landing` at root), `dir_tool` (served at `/`; 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 `/_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 `-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`_v.html`) are immutable; partial-version pins (`_v.html`, `_v.html`) and channel mirrors (`_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v_` per-version binaries plus channel/partial-version symlinks plus `zddc-server_.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 `` — `archive` at `archive/`, `transmittal` at `archive//staging/`, `browse` at `archive//{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive//incoming/`, `tables` at `archive//{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at root), `dir_tool` (served at `/`; 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///`). 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 `/_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. - `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) diff --git a/browse/js/stage.js b/browse/js/stage.js index 869185d..3ff90ae 100644 --- a/browse/js/stage.js +++ b/browse/js/stage.js @@ -1,12 +1,21 @@ // stage.js — Stage and Unstage workflow modals. // -// Stage: move a file from working/<…>/ into a transmittal folder under -// staging/<…>/. Modal lists existing transmittal folders in staging/ -// plus a "New transmittal folder…" option that prompts for a ZDDC- -// conforming name and mkdirs it before the move. +// After the layout reshape, working/ and staging/ live INSIDE each +// party folder: archive//working// and +// archive//staging//. Stage and Unstage are now +// 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// back to the user's -// working// home (overridable). +// Stage: move a file from archive//working/<…> into a +// transmittal folder under archive//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//staging// +// back to the user's archive//working// home +// (overridable). // // Both reuse the existing X-ZDDC-Op: move primitive — no new composite // endpoint is needed; the client just orchestrates one POST per file @@ -26,32 +35,37 @@ } // ── Scope detection: path-shape, not cascade-content ────────────── - // A file is stageable if its containing folder lives under - // //working/<…>. Unstageable if it lives under - // //staging//<…>. Both are path-shape - // queries — content/ACL is enforced server-side. + // A file is stageable if its path matches + // //archive//working/<…>. Unstageable if it + // matches //archive//staging//<…>. + // Both are path-shape queries — content/ACL is enforced server- + // side. - function projectAndSubtree(path) { + // projectPartySlot returns { project, party, slot, rest } when + // path matches //archive///, or + // null on non-match. + function projectPartySlot(path) { var rel = path.replace(/^\/+|\/+$/g, '').split('/'); - if (rel.length < 2) return null; - return { project: rel[0], subtree: rel[1], rest: rel.slice(2) }; + if (rel.length < 4) return null; + if (rel[1].toLowerCase() !== 'archive') return null; + return { project: rel[0], party: rel[2], slot: rel[3], rest: rel.slice(4) }; } function isStageableFile(node) { if (!node || node.isDir || node.virtual) return false; var tree = window.app.modules.tree; if (!tree) return false; - var p = projectAndSubtree(tree.pathFor(node)); - return !!(p && p.subtree === 'working' && p.rest.length >= 1); + var p = projectPartySlot(tree.pathFor(node)); + return !!(p && p.slot === 'working' && p.rest.length >= 1); } function isUnstageableFile(node) { if (!node || node.isDir || node.virtual) return false; var tree = window.app.modules.tree; if (!tree) return false; - var p = projectAndSubtree(tree.pathFor(node)); - // staging// — at least one folder - // segment between staging/ and the file. - return !!(p && p.subtree === 'staging' && p.rest.length >= 2); + var p = projectPartySlot(tree.pathFor(node)); + // archive//staging// — at + // least one folder segment between staging/ and the file. + return !!(p && p.slot === 'staging' && p.rest.length >= 2); } // ── Server helpers ───────────────────────────────────────────────── @@ -69,8 +83,9 @@ return Array.isArray(data) ? data : []; } - async function fetchStagingFolders(project) { - var entries = await listDir('/' + project + '/staging/'); + async function fetchStagingFolders(project, party) { + var entries = await listDir( + '/' + project + '/archive/' + encodeURIComponent(party) + '/staging/'); return entries .filter(function (e) { return e && e.isDir; }) .map(function (e) { return e.name; }); @@ -256,14 +271,15 @@ var tree = window.app.modules.tree; if (!tree) return; var srcUrl = tree.pathFor(node); - var info = projectAndSubtree(srcUrl); - if (!info || info.subtree !== 'working') { - status('Stage applies only to files under working/.', 'error'); + var info = projectPartySlot(srcUrl); + if (!info || info.slot !== 'working') { + status('Stage applies only to files under archive//working/.', 'error'); return; } - var stagingBase = '/' + info.project + '/staging/'; + var stagingBase = '/' + info.project + '/archive/' + + encodeURIComponent(info.party) + '/staging/'; var folders; - try { folders = await fetchStagingFolders(info.project); } + try { folders = await fetchStagingFolders(info.project, info.party); } catch (e) { status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error'); return; @@ -290,20 +306,21 @@ status((e && e.message) || 'move failed', 'error'); 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) { var tree = window.app.modules.tree; if (!tree) return; var srcUrl = tree.pathFor(node); - var info = projectAndSubtree(srcUrl); - if (!info || info.subtree !== 'staging') { - status('Unstage applies only to files under staging/.', 'error'); + var info = projectPartySlot(srcUrl); + if (!info || info.slot !== 'staging') { + status('Unstage applies only to files under archive//staging/.', 'error'); return; } var email = await fetchSelfEmail(); - var defaultTarget = '/' + info.project + '/working/' + (email || '') + '/'; + var defaultTarget = '/' + info.project + '/archive/' + + encodeURIComponent(info.party) + '/working/' + (email || '') + '/'; var choice; try { choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget }); diff --git a/tables/js/editor.js b/tables/js/editor.js index 86edf5a..4271322 100644 --- a/tables/js/editor.js +++ b/tables/js/editor.js @@ -218,6 +218,17 @@ const col = colAt(c); 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); // Complex-type cells (nested object, generic array, oneOf) diff --git a/tables/js/save.js b/tables/js/save.js index ebd97b9..2dffcaf 100644 --- a/tables/js/save.js +++ b/tables/js/save.js @@ -57,7 +57,17 @@ // form-mode and never produce drafts here, so drafts only // contain primitive / string-array values that are safe to // 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) { diff --git a/tests/logo.spec.js b/tests/logo.spec.js index ab60cb8..973d440 100644 --- a/tests/logo.spec.js +++ b/tests/logo.spec.js @@ -52,7 +52,7 @@ test.describe('shared/logo.js', () => { }); test('wraps with href=/ 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 a = document.querySelector('.app-header__logo-link'); 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 }) => { - 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 a = document.querySelector('.app-header__logo-link'); return a && { diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 5757fc4..c3b6ad9 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -1088,6 +1088,31 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } } + // Virtual folder-nav redirect. URLs of the shape + // //{working,staging,reviewing}/[/...] + // 302 to //archive//[/...] — 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 // app-HTML routing or 404, check the two virtual-file-extension // 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 - // directory under each project, populated by the Plan Review - // composite endpoint with physical workflow folders. Falls - // through to the canonical-folder block below. + // (Top-level /{working,staging,reviewing} URLs + // resolve as folder-nav virtuals — the per-party redirect + // is handled above; the bare top-level URL falls through + // to ServeDirectory, where ListDirectory synthesises the + // folder-nav listing from ListPartyDirsInSlot.) // // Virtual received/ window. /received/[...] is a // synthetic view onto the canonical received// diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index e373b49..c7bf475 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -201,17 +201,18 @@ func TestDispatchAppsResolution(t *testing.T) { } // Folder availability rules: classifier should NOT be served at root - // (root has no Incoming/Working/Staging ancestor), but SHOULD work in - // /Project-A/Working/. + // (root has no per-party working/staging/incoming ancestor), but + // SHOULD work at /Project-A/archive//working/ where the per- + // party cascade declares classifier available. rec5 := httptest.NewRecorder() dispatch(cfg, idx, ring, appsSrv, nil, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil)) 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() - 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 { - 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 - // (browse for working/+reviewing/, transmittal for staging/, - // archive for archive/). Mirror of the existing "no-slash → - // default app" behavior at the IsDir branch, extended to cover - // the case where the folder doesn't exist on disk yet. + // No-trailing-slash form on a canonical folder → default app. + // Under the reshape, the project-root staging/reviewing/working + // URLs are folder-nav virtuals served by browse (the per-party + // transmittal default lives at archive//staging/). archive/ + // is still the archive tool. noSlashDefaultApp := []struct { stage string expect string // substring that should appear in the response body }{ {"working", "ZDDC Browse"}, - {"staging", "ZDDC Transmittal"}, + {"staging", "ZDDC Browse"}, {"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"}, } for _, tc := range noSlashDefaultApp { diff --git a/zddc/internal/apps/availability.go b/zddc/internal/apps/availability.go index bbb9f36..e2c7f37 100644 --- a/zddc/internal/apps/availability.go +++ b/zddc/internal/apps/availability.go @@ -37,23 +37,36 @@ func AppAvailableAt(root, requestDir, app string) bool { // which app to serve at a directory URL with no trailing slash — // 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: // -// - /archive//mdl/... → "tables" -// - /archive/ → "archive" -// - /archive//... → "archive" -// - /staging/... → "transmittal" -// - /working/... → "browse" (hosts the -// markdown editor plugin) -// - /reviewing/... → "browse" (operates on the -// virtual aggregator listing) -// - any other directory → "" (no default) +// - /archive//{mdl,rsk}/... → "tables" +// - /archive//staging/... → "transmittal" +// - /archive//{working,reviewing}/... +// → "browse" (hosts the +// markdown editor plugin) +// - /archive//incoming/... → "classifier" +// - /archive//{received,issued}/... +// → "archive" +// - /archive/ → "archive" +// - /{ssr,mdl,rsk} → "tables" (project- +// level rollup virtuals +// with synthesized +// $party column) +// - /{working,staging,reviewing} → "browse" (project- +// level folder-nav +// virtuals — per-party +// URLs 302 to the +// canonical archive/ +// //) +// - any other directory → "" (no default) // -// The mdl rule wins over the broader archive rule because the table -// editor is a more specific surface for browsing planned deliverables -// than the archive index. Note: the dir at archive//mdl/ -// itself IS the table — its table.yaml + form.yaml + row YAMLs all -// live there together (self-contained directory). +// The {mdl,rsk} rule wins over the broader archive rule because the +// table editor is a more specific surface for browsing planned +// deliverables than the archive index. Note: the dir at +// archive//mdl/ itself IS the table — its table.yaml + +// form.yaml + row YAMLs all live there together (self-contained +// directory). // // requestDir and root are absolute filesystem paths; requestDir must // be under root (otherwise "" is returned). diff --git a/zddc/internal/apps/availability_test.go b/zddc/internal/apps/availability_test.go index 5f055cd..7da756d 100644 --- a/zddc/internal/apps/availability_test.go +++ b/zddc/internal/apps/availability_test.go @@ -21,13 +21,13 @@ func TestAppAvailableAt(t *testing.T) { {root, "landing", true}, {root + "/Project-A", "landing", false}, - // classifier: working/, staging/, archive//incoming/ and subtrees + // classifier: per-party working/, staging/, incoming/ subtrees {root, "classifier", false}, {root + "/Project-A", "classifier", false}, - {root + "/Project-A/working", "classifier", true}, - {root + "/Project-A/working/deep/nested/path", "classifier", true}, - {root + "/Project-A/staging", "classifier", true}, - {root + "/Project-A/staging/2026-06-15_x (DFT) - y", "classifier", true}, + {root + "/Project-A/archive/ACME/working", "classifier", true}, + {root + "/Project-A/archive/ACME/working/deep/nested/path", "classifier", true}, + {root + "/Project-A/archive/ACME/staging", "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/sub", "classifier", true}, {root + "/Project-A/archive/ACME/received", "classifier", false}, @@ -37,20 +37,20 @@ func TestAppAvailableAt(t *testing.T) { // browse: universal — every directory has browse available // (it's in the embedded-defaults baseline available_tools). - {root + "/Project-A/working", "browse", true}, - {root + "/Project-A/working/sub", "browse", true}, - {root + "/Project-A/staging", "browse", true}, + {root + "/Project-A/archive/ACME/working", "browse", true}, + {root + "/Project-A/archive/ACME/working/sub", "browse", true}, + {root + "/Project-A/archive/ACME/staging", "browse", true}, {root + "/Project-A/archive/ACME/incoming", "browse", true}, - // transmittal: staging/ only - {root + "/Project-A/staging", "transmittal", true}, - {root + "/Project-A/staging/sub", "transmittal", true}, - {root + "/Project-A/working", "transmittal", false}, + // transmittal: per-party staging/ only + {root + "/Project-A/archive/ACME/staging", "transmittal", true}, + {root + "/Project-A/archive/ACME/staging/sub", "transmittal", true}, + {root + "/Project-A/archive/ACME/working", "transmittal", false}, {root + "/Project-A/archive/ACME/issued", "transmittal", false}, // case-fold: any case of canonical names matches - {root + "/Project-A/Staging", "transmittal", true}, - {root + "/Project-A/STAGING", "transmittal", true}, + {root + "/Project-A/archive/ACME/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}, @@ -79,12 +79,20 @@ func TestDefaultAppAt(t *testing.T) { // Bare project root: no default. Trailing-slash URL serves browse; // no-slash falls through to the redirect. {root + "/Project-A", ""}, - // Canonical project-root folders. + // Project-level virtual aggregators (sibling to archive/). {root + "/Project-A/working", "browse"}, - {root + "/Project-A/working/alice@example.com", "browse"}, - {root + "/Project-A/working/2026-06-15_x (DFT) - y", "browse"}, - {root + "/Project-A/staging", "transmittal"}, - {root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"}, + {root + "/Project-A/staging", "browse"}, + {root + "/Project-A/reviewing", "browse"}, + {root + "/Project-A/ssr", "tables"}, + {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. // Per-party subfolders override per their function: // 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/issued", "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/anything-deeper", "tables"}, - // reviewing/ is virtual; browse hosts the markdown editor that - // 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"}, + {root + "/Project-A/archive/Acme/rsk", "tables"}, // Random non-canonical folder names → no default. {root + "/Project-A/scratch", ""}, // Case-fold on canonical names. - {root + "/Project-A/Working", "browse"}, - {root + "/Project-A/STAGING", "transmittal"}, + {root + "/Project-A/archive/Acme/Working", "browse"}, + {root + "/Project-A/archive/Acme/STAGING", "transmittal"}, {root + "/Project-A/Archive/Acme/MDL", "tables"}, } for _, tc := range cases { diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index 948d5be..4414caf 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -195,32 +195,39 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, result = append(result, fi) } - // Per-user virtual home: when listing /working/ for an - // authenticated viewer, surface a synthetic / entry if - // no real folder of any case variant already exists for them. A - // first write to that path materialises a real folder with auto-own - // .zddc; subsequent listings drop the synthetic entry naturally. + // Per-user virtual home: when listing + // /archive//working/ for an authenticated viewer, + // surface a synthetic / entry if no real folder of + // any case variant already exists for them. A first write to that + // path materialises a real folder with auto-own .zddc; subsequent + // listings drop the synthetic entry naturally. if syn, ok := virtualUserHomeEntry(fsRoot, dirPath, userEmail, baseURL, result); ok { result = append(result, syn) } - // At a project root, surface the four canonical project folders - // (archive/working/staging/reviewing) as virtual entries when no - // on-disk variant exists in any case. The browse client previously - // did this client-side; moving it server-side lets the directory's - // `display:` map apply to virtual entries the same way it applies - // to real ones. + // At a project root, surface the cascade-declared top-level + // folders (archive plus the six virtual aggregators) as virtual + // entries when no on-disk variant exists. The browse client + // previously did this client-side; moving it server-side lets the + // directory's `display:` map apply to virtual entries the same + // way it applies to real ones. result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...) - // Project-level virtual table views: SSR aggregates one row per - // party folder under archive/; MDL/RSK rollups aggregate every - // row from each party's mdl/ or rsk/. The listing surfaces - // synthetic row entries (Writable bit per the canonical - // archive// chain) plus synthetic table.yaml/form.yaml - // entries so the tables tool's client-side walkServer finds the - // spec without a 404 round-trip. Spec bytes are served by the - // main.go IsDefaultSpec fallback; row reads go through - // handler.ServeVirtualViewRow which path-injects name/party. + // Project-level virtual views: + // + // Row rollups (ssr/mdl/rsk) — synthesize row entries (Writable + // bit per the canonical archive// chain) plus synthetic + // table.yaml/form.yaml entries so the tables tool's client-side + // walkServer finds the spec without a 404 round-trip. Spec bytes + // come from main.go IsDefaultSpec fallback; row reads go through + // handler.ServeVirtualViewRow which path-injects name/$party. + // + // Folder-nav (working/staging/reviewing) — synthesize one + // IsDir=true entry per party whose archive/// has + // non-empty content (in-flight filter). The browse client + // follows a click through to the virtual URL + // /// which the dispatcher 302s to the + // canonical archive///. if vv := zddc.ResolveVirtualView(fsRoot, strings.TrimSuffix(baseURL, "/")); vv.Resolved && vv.Kind.IsRootKind() { partyChains := make(map[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, }) } + 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 { case "ssr": @@ -266,12 +286,23 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, partyAbs := filepath.Join(vv.ProjectAbs, "archive", row.Party) appendVirtualRow(row.SyntheticName, partyAbs) } + case "working", "staging", "reviewing": + parties, _ := zddc.ListPartyDirsInSlot(fsRoot, vv.ProjectAbs, vv.Slot) + for _, party := range parties { + partyAbs := filepath.Join(vv.ProjectAbs, "archive", party) + appendVirtualPartyDir(party, partyAbs) + } } - 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}, - ) + // Row rollups carry synthetic spec entries so the tables tool + // can walkServer them. Folder-nav virtuals don't need spec + // files — they're just party listings rendered by browse. + 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 @@ -393,11 +424,18 @@ func virtualCanonicalFolders(fsRoot, absDir, baseURL string, } // virtualUserHomeEntry returns the synthetic / entry that -// should be appended to a working/ listing, or (zero, false) when no -// synthetic entry applies. +// should be appended to a per-party working/ listing, or (zero, false) +// when no synthetic entry applies. +// +// Under the canonical layout, per-user homes live at +// /archive//working// (depth-4 working slot +// inside the party folder). The synthetic entry fires when dirPath +// case-folds to /archive//working and the viewer has +// no real home folder yet. // // Conditions for the entry to fire: -// - dirPath case-folds to /working at depth-2 of fsRoot +// - dirPath case-folds to /archive//working at +// depth-4 of fsRoot // - viewerEmail is non-empty // - real does not already contain a directory entry that case-folds // to viewerEmail (so a materialised home doesn't get duplicated) @@ -407,7 +445,9 @@ func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []l } rel := strings.Trim(filepath.ToSlash(dirPath), "/") 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 } for _, fi := range real { diff --git a/zddc/internal/fs/tree_test.go b/zddc/internal/fs/tree_test.go index e3610be..b6ded6c 100644 --- a/zddc/internal/fs/tree_test.go +++ b/zddc/internal/fs/tree_test.go @@ -21,14 +21,20 @@ func setupTreeRoot(t *testing.T) string { return root } +// Per-user homes now live at archive//working// (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) { 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) } 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 { t.Fatalf("list: %v", err) } @@ -51,12 +57,14 @@ func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) { func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) { root := setupTreeRoot(t) // 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) } 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 { t.Fatalf("list: %v", err) } @@ -69,12 +77,14 @@ func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) { func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.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) } 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 { t.Fatalf("list: %v", err) } @@ -87,12 +97,14 @@ func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) { func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.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) } 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 { t.Fatalf("list: %v", err) } @@ -105,15 +117,15 @@ func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) { func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) { root := setupTreeRoot(t) - // Listing inside working/ at depth 3+ — no synthetic entry should fire. - if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "alice@example.com"), 0o755); err != nil { + // Listing inside working// — no synthetic entry should fire. + if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com"), 0o755); err != nil { t.Fatal(err) } zddc.InvalidateCache(root) got, err := ListDirectory(context.Background(), nil, root, - "Proj/working/alice@example.com", "alice@example.com", - "/Proj/working/alice@example.com/", false, false) + "Proj/archive/Acme/working/alice@example.com", "alice@example.com", + "/Proj/archive/Acme/working/alice@example.com/", false, false) if err != nil { t.Fatalf("list: %v", err) } @@ -126,13 +138,15 @@ func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) { func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) { root := setupTreeRoot(t) - // Pre-existing PascalCase Working/. - if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil { + // Pre-existing PascalCase Working/ under archive//. + if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme", "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) + got, err := ListDirectory(context.Background(), nil, root, + "Proj/archive/Acme/Working", "alice@example.com", + "/Proj/archive/Acme/Working/", false, false) if err != nil { 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 -// nav links into /working/ etc. unconditionally; this keeps -// fresh projects (no working/ on disk yet) from 404'ing. +// nav links into /archive/ etc. unconditionally; this keeps +// 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) { root := setupTreeRoot(t) - // Proj exists but Proj/working/ does NOT. - if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil { + // Proj exists; the party folder skeleton does not. + if err := os.MkdirAll(filepath.Join(root, "Proj", "archive", "Acme"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "Proj", ".zddc"), @@ -163,29 +180,31 @@ func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) { } 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, - "Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false, false) + dirPath, "alice@example.com", baseURL, false, false) 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 } - // working/ surfaces a synthetic / entry; the others - // should be a flat empty listing. + // working/ surfaces a synthetic / entry; the + // others should be a flat empty listing. if stage == "working" { 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 { 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 -// only applies to the four canonical project-root folders. +// only applies to cascade-declared paths. func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) { root := setupTreeRoot(t) 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) } } + +// 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) + } +} diff --git a/zddc/internal/handler/auth_invariants_test.go b/zddc/internal/handler/auth_invariants_test.go index 0968f69..9d06c06 100644 --- a/zddc/internal/handler/auth_invariants_test.go +++ b/zddc/internal/handler/auth_invariants_test.go @@ -27,8 +27,9 @@ import ( // invariantsFixture sets up a synthetic ZDDC root with: // // - admin@example.com — root super-admin -// - alice@example.com — subtree admin of Project-1/working (via per-dir -// .zddc admins:) — used to test subtree scope +// - alice@example.com — subtree admin of Project-1/archive/Acme/working +// (via per-dir .zddc admins:) — used to test +// subtree scope // - bob@example.com — document_controller role member (gets WORM cr // on received/ + issued/ via cascade defaults) // - 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") 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/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, - filepath.Join(root, "Project-1/working/.zddc"), + filepath.Join(root, "Project-1/archive/Acme/working/.zddc"), "admins:\n - alice@example.com\n") // Files to act on. 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") mustWriteHelper(t, 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 // super-admin must return Forbidden. 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"), "") if rec.Code != http.StatusForbidden { 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 // AllowActionFromChainP and the file API write proceeds. 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"), "") 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()) @@ -148,9 +149,9 @@ func TestInvariant_ElevatedAdminBypassesWorm(t *testing.T) { func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.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"), "") - // 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. 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()) @@ -159,7 +160,7 @@ func TestInvariant_ElevatedSubtreeAdminWritesInScope(t *testing.T) { func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.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" rec := doReq(cfg, http.MethodPut, target, "alice@example.com", true, []byte("# out-of-scope\n"), "") if rec.Code != http.StatusForbidden { @@ -178,7 +179,7 @@ func TestInvariant_ElevatedSubtreeAdminBlockedOutsideScope(t *testing.T) { func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) { cfg, _ := invariantsFixture(t) 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) if err != nil { t.Fatalf("EffectivePolicy: %v", err) @@ -191,7 +192,7 @@ func TestInvariant_SubtreeAdminCanEditOwnSubtreeZddc(t *testing.T) { func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) { cfg, _ := invariantsFixture(t) 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) if err != nil { t.Fatalf("EffectivePolicy: %v", err) @@ -205,7 +206,7 @@ func TestInvariant_SubtreeAdminCanEditDeeperZddc(t *testing.T) { func TestInvariant_EmptyEmailHasNoAuthority(t *testing.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"), "") if rec.Code != http.StatusForbidden && rec.Code != http.StatusUnauthorized { 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; // write must materialise it; root // 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: // list (so alice's write doesn't // require root-admin authority). @@ -340,12 +341,12 @@ func TestInvariant_ZddcPutMatrix(t *testing.T) { {"non-admin → project .zddc", "/Project-1/.zddc", nonAdmin, den}, // Subtree .zddc (alice administers this subtree) - {"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, ok}, - {"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, den}, - {"subtree admin elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, ok}, - {"subtree admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, den}, - {"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, den}, - {"anonymous → subtree .zddc", "/Project-1/working/.zddc", anon, den}, + {"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, ok}, + {"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, den}, + {"subtree admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, ok}, + {"subtree admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, den}, + {"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, den}, + {"anonymous → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", anon, den}, } for _, tc := range cases { @@ -386,11 +387,11 @@ func TestInvariant_ZddcDeleteMatrix(t *testing.T) { who principal want int }{ - {"root admin elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminElevated, http.StatusNoContent}, - {"root admin un-elevated → subtree .zddc", "/Project-1/working/.zddc", rootAdminUnelevated, http.StatusForbidden}, - {"subtree admin elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminElevated, http.StatusNoContent}, - {"subtree admin un-elevated → own .zddc", "/Project-1/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden}, - {"non-admin → subtree .zddc", "/Project-1/working/.zddc", nonAdmin, http.StatusForbidden}, + {"root admin elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminElevated, http.StatusNoContent}, + {"root admin un-elevated → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", rootAdminUnelevated, http.StatusForbidden}, + {"subtree admin elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminElevated, http.StatusNoContent}, + {"subtree admin un-elevated → own .zddc", "/Project-1/archive/Acme/working/.zddc", subtreeAdminUnelevated, http.StatusForbidden}, + {"non-admin → subtree .zddc", "/Project-1/archive/Acme/working/.zddc", nonAdmin, http.StatusForbidden}, } for _, tc := range cases { @@ -423,14 +424,14 @@ func TestInvariant_UnelevatedAdminNoSilentBypass(t *testing.T) { probes := []op{ // .zddc writes (ActionAdmin) {http.MethodPut, "/.zddc", []byte("title: x\n"), ""}, - {http.MethodPut, "/Project-1/working/.zddc", []byte("title: x\n"), ""}, - {http.MethodDelete, "/Project-1/working/.zddc", nil, ""}, + {http.MethodPut, "/Project-1/archive/Acme/working/.zddc", []byte("title: x\n"), ""}, + {http.MethodDelete, "/Project-1/archive/Acme/working/.zddc", nil, ""}, // 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/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, ""}, // 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 { diff --git a/zddc/internal/handler/default-project-mdl.table.yaml b/zddc/internal/handler/default-project-mdl.table.yaml index 4675447..6cc895a 100644 --- a/zddc/internal/handler/default-project-mdl.table.yaml +++ b/zddc/internal/handler/default-project-mdl.table.yaml @@ -4,21 +4,23 @@ # # This view aggregates every deliverable row from every party under # /archive/. Each synthetic row is backed by the real file -# at /archive//mdl/.yaml; the leading `party` -# column is derived from the row's source folder (path-injected by -# the server, not stored in the YAML). +# at /archive//mdl/.yaml; the leading `$party` +# column is the server-synthesized source-party identity (path- +# 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 -# routing key — the server reads the submitted `party` field, finds -# the matching /archive// folder, and writes the row -# inside its mdl/ subfolder. The party folder must already exist -# (create it via the SSR view). +# + Add row IS enabled here: the form schema's `party` field doubles +# as the routing key — the server reads the submitted `party` field, +# finds the matching /archive// folder, and writes +# the row inside its mdl/ subfolder. The party folder must already +# exist (create it via the SSR view). 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//mdl/ folder. columns: - - field: party + - field: $party title: Package width: 7em - field: originator @@ -64,5 +66,5 @@ columns: defaults: sort: - - { field: party, dir: asc } + - { field: $party, dir: asc } - { field: plannedDate, dir: asc } diff --git a/zddc/internal/handler/default-project-rsk.table.yaml b/zddc/internal/handler/default-project-rsk.table.yaml index 1176925..d2b6854 100644 --- a/zddc/internal/handler/default-project-rsk.table.yaml +++ b/zddc/internal/handler/default-project-rsk.table.yaml @@ -3,21 +3,23 @@ # # This view aggregates every risk row from every party under # /archive/. Each synthetic row is backed by the real file -# at /archive//rsk/.yaml; the leading `party` -# column is derived from the row's source folder (path-injected by -# the server, not stored in the YAML). +# at /archive//rsk/.yaml; the leading `$party` +# column is the server-synthesized source-party identity (path- +# 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 -# routing key — the server reads the submitted `party` field, finds -# the matching /archive// folder, and writes the row -# inside its rsk/ subfolder. The party folder must already exist -# (create it via the SSR view). +# + Add row IS enabled here: the form schema's `party` field doubles +# as the routing key — the server reads the submitted `party` field, +# finds the matching /archive// folder, and writes +# the row inside its rsk/ subfolder. The party folder must already +# exist (create it via the SSR view). 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//rsk/ folder. columns: - - field: party + - field: $party title: Package width: 7em - field: id @@ -52,4 +54,4 @@ columns: defaults: sort: - { field: severity, dir: desc } - - { field: party, dir: asc } + - { field: $party, dir: asc } diff --git a/zddc/internal/handler/directory.go b/zddc/internal/handler/directory.go index 18770eb..3317215 100644 --- a/zddc/internal/handler/directory.go +++ b/zddc/internal/handler/directory.go @@ -147,12 +147,16 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit if dt := zddc.DefaultToolAt(cfg.Root, absDir); dt != "" { w.Header().Set("X-ZDDC-Default-Tool", dt) } - // X-ZDDC-On-Plan-Review surfaces whether the cascade above this - // path has an on_plan_review block configured. Browse uses it to + // X-ZDDC-On-Plan-Review surfaces whether this path is eligible for + // the Plan Review composite endpoint — true at every URL of the + // shape //archive//received//, which is + // the only shape the handler accepts. Browse uses the header to // show/hide the "Plan Review" right-click menu item without - // re-implementing the cascade client-side. Boolean; absent header - // = false. - if zddc.OnPlanReviewAt(cfg.Root, absDir) != nil { + // duplicating the URL test client-side. Boolean; absent header = + // false. (Replaced the previous cascade-keyed on_plan_review check + // when the layout reshape made archive//{reviewing,staging}/ + // the hardcoded scaffold target — see handler/planreview.go.) + if zddc.IsPlanReviewURL(urlPath) { w.Header().Set("X-ZDDC-On-Plan-Review", "true") } // X-ZDDC-Canonical-Folder names the canonical project-layout slot diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index 173283d..b3690c5 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -694,6 +694,17 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) { return } + // Project-root mkdir policy: the only physical child allowed + // directly under / 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). if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil { 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 ZDDC transmittal-folder grammar AND its tracking number contains - // -SUB- or -TRN-, also create the same-named folder under working/ as - // a drafting space for staff. The mirror is one-way and one-shot — - // renames or deletions of either side are not propagated. - if email != "" { - mirrorStagingToWorking(cfg, abs, email) - } + // (The pre-reshape staging↔working mirror was retired: with + // staging at archive//staging// and working at + // archive//working//, the project-level pairing + // no longer maps cleanly. Operators who want a per-batch drafting + // space create it inside their own working// home.) w.Header().Set("X-ZDDC-Source", "fileapi:mkdir") w.WriteHeader(http.StatusCreated) auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil) } -// mirrorStagingToWorking creates a paired drafting folder under working/ -// when newAbs is a transmittal-named folder under /staging/. Best -// effort — failures are logged but do not affect the staging mkdir result. +// rejectProjectRootMkdir reports whether a mkdir at abs lands at +// // where is forbidden as a direct project- +// root physical child. Under the canonical layout: // -// Eligibility: -// - newAbs's parent is exactly /staging/ (case-fold) -// - filepath.Base(newAbs) parses as a transmittal folder -// (YYYY-MM-DD_ () - ) -// - tracking contains -SUB- or -TRN- (case-fold) +// - `archive` is the only physical project-root canonical folder +// - `_`-/`.`-prefixed names are system-reserved and allowed +// - the six virtual aggregator names (ssr/mdl/rsk/working/staging/ +// reviewing) are explicitly rejected — the virtual resolver +// 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: -// - <project>/working/ created if missing, with auto-own .zddc seeded -// (via EnsureCanonicalAncestors) -// - <project>/working/<sameName>/ created if missing, with its own -// auto-own .zddc (it's a child of the working/ canonical folder) -func mirrorStagingToWorking(cfg config.Config, newAbs, email string) { - rel, err := filepath.Rel(cfg.Root, newAbs) +// Returns (true, reason) when the request should be 409'd. Returns +// (false, "") when the target is at any other depth or carries an +// allowed name. +func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) { + rel, err := filepath.Rel(fsRoot, abs) if err != nil { - return + return false, "" } rel = filepath.ToSlash(rel) + if rel == "." || strings.HasPrefix(rel, "../") { + return false, "" + } parts := strings.Split(rel, "/") - if len(parts) != 3 { - // Mirror only fires for direct children of staging/. Deeper paths - // (staging/<name>/sub/) are user-managed. - return + if len(parts) != 2 { + // Not a direct project-root child — depth-2 = <project>/<name>. + return false, "" } - if !strings.EqualFold(parts[1], "staging") { - return + name := parts[1] + if name == "archive" { + return false, "" } - - name := parts[2] - _, tracking, _, _, ok := zddc.ParseTransmittalFolder(name) - if !ok || !zddc.IsTrnOrSubTracking(tracking) { - return + if strings.HasPrefix(name, "_") || strings.HasPrefix(name, ".") { + // System-reserved namespace; allowed. + return false, "" } - - mirrorPath := filepath.Join(cfg.Root, parts[0], "working", name) - // Idempotent: skip if the working sibling already exists. - if info, err := os.Stat(mirrorPath); err == nil && info.IsDir() { - 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) + lower := strings.ToLower(name) + switch lower { + case "ssr", "mdl", "rsk", "working", "staging", "reviewing": + return true, "Conflict — " + lower + "/ is a project-level virtual aggregator and cannot be created as a physical folder. Files of this kind live under archive/<party>/" + lower + "/." } + return true, "Conflict — 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. diff --git a/zddc/internal/handler/fileapi_test.go b/zddc/internal/handler/fileapi_test.go index f756c19..f10f903 100644 --- a/zddc/internal/handler/fileapi_test.go +++ b/zddc/internal/handler/fileapi_test.go @@ -7,7 +7,6 @@ import ( "encoding/hex" "net/http" "net/http/httptest" - "net/url" "os" "path/filepath" "strings" @@ -306,15 +305,18 @@ func TestFileAPI_PostMissingOp(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", }) if rec.Code != http.StatusCreated { 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 { t.Fatalf("stat: %v", err) } @@ -324,8 +326,8 @@ func TestFileAPI_MkdirCreates(t *testing.T) { } func TestFileAPI_MkdirIdempotent(t *testing.T) { - _, do, _ := fileAPITestSetup(t, []string{"Incoming/exists"}, nil) - rec := do(http.MethodPost, "/Incoming/exists/", "alice@example.com", nil, map[string]string{ + _, do, _ := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming/exists"}, nil) + rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/exists/", "alice@example.com", nil, map[string]string{ "X-ZDDC-Op": "mkdir", }) 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) { _, do, _ := fileAPITestSetup(t, nil, map[string]string{ "Incoming/x.txt": "v1", @@ -630,145 +667,7 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) { } } -// --- staging↔working mirror ------------------------------------------------- - -// stagingMirrorURL builds a URL-safe target path for a transmittal folder -// name with spaces and parens, mirroring how a real client would encode it. -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) - } -} +// (The pre-reshape staging↔working mirror was retired: with staging at +// archive/<party>/staging/<batch>/ and working at archive/<party>/ +// working/<email>/, the project-level pairing no longer maps cleanly. +// Tests for the removed behaviour have been deleted.) diff --git a/zddc/internal/handler/planreview.go b/zddc/internal/handler/planreview.go index 0a9d34b..39b9372 100644 --- a/zddc/internal/handler/planreview.go +++ b/zddc/internal/handler/planreview.go @@ -31,9 +31,10 @@ import ( // cascade defaults; the same `c` (write-once-create) verb that // lets them file canonical submittals lets them establish this // .zddc once. -// - ActionAdmin on reviewing_root/.zddc + staging_root/.zddc. The -// invoker must already administer those subtrees per the cascade -// defaults. +// - ActionAdmin on archive/<party>/reviewing/.zddc and +// archive/<party>/staging/.zddc. The invoker must already +// administer those subtrees per the cascade defaults (which give +// subtree-admin of the party folder to document_controller). // // 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 // 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) { receivedRel := filepath.ToSlash(filepath.Join("archive", party, "received", tracking)) receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel)) cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/" - prCfg := zddc.OnPlanReviewAt(cfg.Root, receivedAbs) - if prCfg == nil || prCfg.ReviewingRoot == "" || prCfg.StagingRoot == "" { - return nil, http.StatusConflict, "Conflict — on_plan_review is not configured in the cascade for this subtree" - } - reviewingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.ReviewingRoot, "/"))) - stagingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.StagingRoot, "/"))) + // Hardcoded path convention. Every project has exactly one + // reviewing/ and one staging/ slot per party at fixed offsets; + // the composite endpoint scaffolds inside the originating party's + // slots. + reviewingRoot := filepath.Join(cfg.Root, project, "archive", party, "reviewing") + stagingRoot := filepath.Join(cfg.Root, project, "archive", party, "staging") // Pre-flight authorisation. No ACL exception — we use existing // cascade grants: - // (a) ActionAdmin on reviewing_root and staging_root proves the - // invoker is subtree-admin of the workflow roots and can - // write the workflow .zddc files. + // (a) ActionAdmin on archive/<party>/reviewing/ and + // archive/<party>/staging/ proves the invoker is subtree- + // 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 // received/<tracking>/. For the doc_controller this comes // from `worm: [document_controller]` on received/ in the diff --git a/zddc/internal/handler/planreview_test.go b/zddc/internal/handler/planreview_test.go index 38616d9..881483f 100644 --- a/zddc/internal/handler/planreview_test.go +++ b/zddc/internal/handler/planreview_test.go @@ -200,7 +200,7 @@ func TestPlanReview_Idempotent(t *testing.T) { } // 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) if err != nil { t.Fatalf("read %s: %v", reviewingRoot, err) @@ -247,7 +247,7 @@ func TestPlanReview_ReceivedZddcIsWriteOnce(t *testing.T) { } // 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) if err != nil { t.Fatalf("read %s: %v", reviewingRoot, err) @@ -274,11 +274,12 @@ func TestPlanReview_Forbidden(t *testing.T) { if rec.Code != http.StatusForbidden { 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 // happens AFTER the ACL check in the handler, so refusal // guarantees no state change. - entries, _ := os.ReadDir(filepath.Join(root, "Project-1", "reviewing")) + entries, _ := os.ReadDir(reviewingRoot) if len(entries) > 0 { t.Errorf("reviewing/ created despite 403: %d entries", len(entries)) } diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index d78c88d..f55f1cb 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1515,7 +1515,7 @@ body.is-elevated::after { </svg> <div class="header-title-group"> <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 class="header-right"> @@ -4219,6 +4219,17 @@ body.is-elevated::after { const col = colAt(c); 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); // 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 // contain primitive / string-array values that are safe to // 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) { diff --git a/zddc/internal/handler/virtualviewhandler.go b/zddc/internal/handler/virtualviewhandler.go index 198d2e2..8ffb0be 100644 --- a/zddc/internal/handler/virtualviewhandler.go +++ b/zddc/internal/handler/virtualviewhandler.go @@ -8,13 +8,19 @@ // doesn't carry: // // - SSR rows get `name: <party>` so the table renderer has a column -// to sort on and the form edit pre-fills the party name. -// - MDL / RSK rollup rows get `party: <party>` so the rollup table -// can show which package each row came from. +// to sort on and the form edit pre-fills the party name. (Identity +// of an SSR row is the party folder name, so the field is named +// 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 // 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 // submit, which the tables/form JS already does for path-derived // fields). @@ -79,7 +85,7 @@ func ServeVirtualViewRow(w http.ResponseWriter, r *http.Request, vv zddc.Virtual case zddc.VirtualViewSSRRow: data["name"] = vv.Party case zddc.VirtualViewMDLRow, zddc.VirtualViewRSKRow: - data["party"] = vv.Party + data["$party"] = vv.Party } out, err := yaml.Marshal(data) diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index e54dbfc..d71604f 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -33,19 +33,20 @@ acl: # `reset: true` on the role at that level — ancestor definitions above # the reset are then excluded. # -# document_controller — the people who file into archive/<party>/ -# received/ and issued/ (WORM zones). They get read+write-once- -# create there (via the worm: lists below) and read/write -# elsewhere in a project, plus subtree-admin of working/ and -# staging/ so they can stand up new top-level folders and manage -# user/staging subtrees. They are NOT subtree-admin of archive/, -# so the WORM constraint still binds them in received/issued. +# document_controller — the people who file into +# archive/<party>/received/ and issued/ (WORM zones). They get +# read+write-once-create there (via the worm: lists below) and +# read/write elsewhere in a project, plus subtree-admin of the +# per-party working/ + staging/ + reviewing/ so they can stand up +# and manage drafting/transmittal/review folders. They are NOT +# 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 -# the project. Their own working/<email>/ home and anything they -# create under incoming/ get a creator-owned auto-own .zddc -# (rwcda) which wins via deepest-match, so "read-only except -# what I own" falls out of the cascade with no special rule. +# the project. Their own archive/<party>/working/<email>/ home and +# anything they create under incoming/ get a creator-owned auto- +# own .zddc (rwcda) which wins via deepest-match, so "read-only +# except what I own" falls out of the cascade with no special rule. roles: document_controller: members: [] @@ -89,17 +90,32 @@ available_tools: [archive, browse, landing] # # ── Canonical project structure ──────────────────────────────────────────── # -# Every ZDDC project lives at a top-level directory. Under it the -# convention is four canonical folders: archive (formal record), -# working (in-progress workspace), staging (outbound prep), reviewing -# (Plan-Review-managed draft workspaces). Under archive/<party>/ the -# convention is four more: mdl (deliverables list), incoming (counterparty -# drop zone), received (immutable submittals), issued (immutable responses). +# Every ZDDC project lives at a top-level directory. Under it +# `archive/` is the ONLY real top-level folder; it contains a folder +# per party. Everything party-scoped (the SSR row, MDL/RSK rollups, +# WORM received/issued, the incoming drop zone, and the in-flight +# lifecycle slots working/staging/reviewing) lives uniformly under +# archive/<party>/. # -# All of this 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. +# Six top-level virtuals sit beside archive/ as resolver views: +# +# ssr mdl rsk tables rollups across parties +# (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 # on-disk .zddc and changing what they need; on-disk values win. @@ -119,15 +135,43 @@ paths: permissions: project_team: r 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: + # ── 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: default_tool: archive # The doc controller can create party subfolders here @@ -145,12 +189,20 @@ paths: # to received/issued). That lets them set up the # counterparty's own .zddc afterward. 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 # party's vendor / contract / status data. Scoped by # filename pattern so the lock on `kind` only applies to - # ssr.yaml — the mdl/, rsk/, received/ subfolders are - # untouched. No filename_format because identity is the - # party folder name, not a composed tracking number. + # ssr.yaml — the mdl/, rsk/, received/, working/, + # staging/, reviewing/ subfolders are untouched. No + # filename_format because identity is the party folder + # name, not a composed tracking number. records: "ssr.yaml": field_defaults: @@ -251,74 +303,47 @@ paths: issued: default_tool: archive worm: [document_controller] - working: - default_tool: browse - available_tools: [browse, classifier] - # working/ auto-owns the first creator + the per-user homes - # below. - auto_own: true - drop_target: true - # Doc controller is subtree-admin of working/ — full create - # + manage, including taking over a fenced per-user home if a - # user leaves. (Scoped here, not at the project root, so the - # WORM constraint in archive/<party>/received|issued still - # binds them.) - admins: [document_controller] - paths: - "*": # per-user home dir - default_tool: browse - available_tools: [browse, classifier] - auto_own: true - # Per-user home is private by default: the generated - # auto-own .zddc carries inherit:false so ancestor ACL - # grants don't reach inside. The user can edit the file - # to grant collaborators access. - auto_own_fenced: true - drop_target: true - staging: - default_tool: transmittal - available_tools: [transmittal, classifier] - auto_own: true - drop_target: true - # Doc controller is subtree-admin of staging/ too — same - # rationale as working/. - admins: [document_controller] - reviewing: - default_tool: browse - available_tools: [browse] - # reviewing/ is the doc-controller's draft-workspace area. The - # "Plan Review" composite endpoint (see on_plan_review at project - # level) scaffolds a physical folder here for each submittal - # under review, with a .zddc carrying received_path back to the - # canonical submittal in received/. Subtree-admin so the doc - # controller can author per-folder .zddc files (originator ACL, - # planned_date). - auto_own: 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 + # ── In-flight lifecycle slots (NEW — nested per-party) ──── + # + # working/staging/reviewing now live inside each party + # folder instead of at the project root. The project- + # level <project>/{working,staging,reviewing} virtuals + # (declared above) are folder-nav views over these + # canonical per-party slots. + working: + default_tool: browse + available_tools: [browse, classifier] + # working/ auto-owns the first creator + the per-user + # homes below. + auto_own: true + drop_target: true + paths: + "*": # per-user home dir, fenced + default_tool: browse + available_tools: [browse, classifier] + auto_own: true + # Per-user home is private by default: the generated + # auto-own .zddc carries inherit:false so ancestor ACL + # grants don't reach inside. The user can edit the file + # to grant collaborators access. + auto_own_fenced: true + drop_target: true + staging: + default_tool: transmittal + available_tools: [transmittal, classifier] + auto_own: true + drop_target: true + reviewing: + default_tool: browse + available_tools: [browse] + # reviewing/ is the doc-controller's draft-workspace + # area inside this party folder. The "Plan Review" + # composite endpoint scaffolds a physical folder here + # for each submittal under review, with a .zddc + # carrying received_path back to the canonical + # submittal in received/. Subtree-admin (inherited + # from the party-level admins:) so the doc + # controller can author per-folder .zddc files + # (originator ACL, planned_date). + auto_own: true + drop_target: true diff --git a/zddc/internal/zddc/ensure.go b/zddc/internal/zddc/ensure.go index 705da1c..df730f5 100644 --- a/zddc/internal/zddc/ensure.go +++ b/zddc/internal/zddc/ensure.go @@ -51,7 +51,7 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) { if len(parts) >= 2 { seg := strings.ToLower(parts[1]) - if seg == "archive" || seg == "working" || seg == "staging" { + if seg == "archive" { if err := resolveAt(1, seg); err != nil { return target, err } @@ -60,7 +60,8 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) { if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") { seg := strings.ToLower(parts[3]) switch seg { - case "mdl", "incoming", "received", "issued": + case "mdl", "rsk", "incoming", "received", "issued", + "working", "staging", "reviewing": if err := resolveAt(3, seg); err != nil { return target, err } @@ -70,26 +71,26 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) { } // EnsureCanonicalAncestors walks from fsRoot down to filepath.Dir(target), -// creating any missing canonical-folder ancestor with MkdirAll(perm). For -// freshly-created auto-own ancestors (working/, staging/, or -// archive/<party>/incoming/), it also writes a creator-owned .zddc using -// principalEmail (skipped if principalEmail is empty). +// creating any missing canonical-folder ancestor with MkdirAll(perm). +// For freshly-created auto-own ancestors (archive/<party>/, and the per- +// party lifecycle slots {working,staging,reviewing,incoming}), it also +// writes a creator-owned .zddc using principalEmail (skipped if +// principalEmail is empty). // // Returns the resolved version of target with on-disk casing substituted // for any canonical ancestor whose disk variant differs from the requested -// casing — so a pre-existing Working/ is reused rather than shadowed by a -// new working/ sibling. The basename of target is never altered. +// casing — so a pre-existing Archive/ is reused rather than shadowed by a +// new archive/ sibling. The basename of target is never altered. // // Canonical positions, relative to fsRoot: // -// - <project>/<canonical-root> where <canonical-root> ∈ -// {archive, working, staging} +// - <project>/archive (the only physical project-root canonical; +// 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 -// <canonical-party> ∈ {mdl, incoming, received, issued} -// -// "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). +// <canonical-party> ∈ {mdl, rsk, incoming, received, issued, +// working, staging, reviewing} // // fsRoot and target must be absolute filesystem paths under the same // volume; target may not yet exist on disk. @@ -109,9 +110,15 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil return target, nil } - // Reject writes under reviewing/ — virtual route. - if len(parts) >= 2 && strings.EqualFold(parts[1], "reviewing") { - return target, fmt.Errorf("reviewing/ is virtual and not writable") + // Reject writes targeting top-level virtual aggregators — + // <project>/{ssr,mdl,rsk,working,staging,reviewing}/... — these + // resolve through ResolveVirtualView, not as physical paths. A + // caller writing under them bypassed the virtual resolver. + 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)) @@ -155,21 +162,23 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil // Walk depth 1 (project) → deeper levels, resolving + tracking as we go. // Depth 0 is the project segment; not a canonical name. if len(parts) >= 2 { - // Depth 1 candidate: archive / working / staging. + // Depth 1 candidate: archive (only physical project-root canonical). seg := strings.ToLower(parts[1]) - if seg == "archive" || seg == "working" || seg == "staging" { + if seg == "archive" { if err := resolveAt(1, seg); err != nil { return target, err } } } - // Depth 3 candidate (archive/<party>/<canonical-party>): mdl / incoming / - // received / issued. Only meaningful when depth 1 is "archive". + // Depth 3 candidate (archive/<party>/<canonical-party>): the eight + // physical per-party slots. Only meaningful when depth 1 is + // "archive". if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") { seg := strings.ToLower(parts[3]) switch seg { - case "mdl", "incoming", "received", "issued": + case "mdl", "rsk", "incoming", "received", "issued", + "working", "staging", "reviewing": if err := resolveAt(3, seg); err != nil { return target, err } diff --git a/zddc/internal/zddc/ensure_test.go b/zddc/internal/zddc/ensure_test.go index 84142d3..6891be0 100644 --- a/zddc/internal/zddc/ensure_test.go +++ b/zddc/internal/zddc/ensure_test.go @@ -9,7 +9,11 @@ import ( func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) { 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) 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) } - // working/ is now created with auto-own .zddc. - autoZ := filepath.Join(root, "Proj", "working", ".zddc") + // working/ is now created with auto-own .zddc (unfenced — party + // 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) if err != nil { 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") { 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 // default so other users can't read alice's drafts via ancestor // cascade. alice can edit the file later to add collaborators. - homeZddc := filepath.Join(root, "Proj", "working", "alice@x.com", ".zddc") - if _, err := os.Stat(filepath.Join(root, "Proj", "working", "alice@x.com")); err != nil { + homeZddc := filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com", ".zddc") + if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "ACME", "working", "alice@x.com")); err != nil { t.Errorf("subfolder not created: %v", err) } homeData, err := os.ReadFile(homeZddc) @@ -58,47 +67,52 @@ func TestEnsureCanonicalAncestors_LazyCreation(t *testing.T) { // under working/ get the fence. func TestEnsureCanonicalAncestors_StagingChildNotFenced(t *testing.T) { 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") if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil { t.Fatalf("ensure: %v", err) } // staging/<folder>/.zddc should not exist (only the parent staging/ // 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") if _, err := os.Stat(childZddc); !os.IsNotExist(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) { root := t.TempDir() - // Pre-create Working/ (PascalCase). - if err := os.MkdirAll(filepath.Join(root, "Proj", "Working"), 0o755); err != nil { + // Pre-create Archive/ (PascalCase) — case-fold reuse applies to + // the canonical project-root slot. + if err := os.MkdirAll(filepath.Join(root, "Proj", "Archive", "ACME", "working"), 0o755); err != nil { 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) if err != nil { t.Fatalf("ensure: %v", err) } - // Resolved path uses on-disk Working/ casing. - want := filepath.Join(root, "Proj", "Working", "foo.md") + // Resolved path uses on-disk Archive/ casing. + want := filepath.Join(root, "Proj", "Archive", "ACME", "working", "foo.md") if resolved != want { t.Errorf("resolved=%q, want %q", resolved, want) } - // No new working/ sibling. - if _, err := os.Stat(filepath.Join(root, "Proj", "working")); !os.IsNotExist(err) { + // No new lowercase archive/ sibling. + if _, err := os.Stat(filepath.Join(root, "Proj", "archive")); !os.IsNotExist(err) { t.Errorf("lowercase sibling should not exist; got err=%v", err) } - // Working/ already existed before our call — no auto-own .zddc was - // retroactively written. - if _, err := os.Stat(filepath.Join(root, "Proj", "Working", ".zddc")); !os.IsNotExist(err) { + // Archive/ already existed — no auto-own .zddc was retroactively written. + if _, err := os.Stat(filepath.Join(root, "Proj", "Archive", ".zddc")); !os.IsNotExist(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) { 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) if err != nil { 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) } - 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) } } -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() - target := filepath.Join(root, "Proj", "reviewing", "x.md") - _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755) - if err == nil { - t.Errorf("expected error for write under reviewing/, got nil") - } - 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) + for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} { + target := filepath.Join(root, "Proj", slot, "x.md") + _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755) + 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", slot)); !os.IsNotExist(err) { + t.Errorf("%s: <project>/%s/ must NOT be created on disk; got err=%v", slot, slot, err) + } } } diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index 9620133..72304fa 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -76,18 +76,6 @@ type Role struct { 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 // server-side MD→{docx,html,pdf} conversion endpoint. The handler // 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"` 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 // components of tracking numbers and as constrained body fields // on record YAMLs (mdl rows, rsk rows, ssr rows). The map key is diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index 6e09055..222ca75 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -226,58 +226,45 @@ func ChildrenDeclaredAt(fsRoot, dirPath string) []string { // CanonicalFolderAt returns the canonical-folder name for THIS specific // directory — one of "archive", "working", "staging", "reviewing", -// "incoming", "received", "issued", "mdl" — or "" if the path is not -// at a canonical-folder slot. +// "incoming", "received", "issued", "mdl", "rsk" — or "" if the path +// is not at a canonical-folder slot. // // Detection is structural against the canonical project layout declared -// in defaults.zddc.yaml: top-level <project>/{archive,working,staging, -// reviewing} and the second-level archive/<party>/{mdl,incoming, -// received,issued}. Operators don't rename these slots (the cascade -// keys them by literal name); a custom layout that does is on its own. +// in defaults.zddc.yaml: +// +// - top-level <project>/archive is the only physical project-root +// 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, // Stage/Unstage, Create Transmittal folder) without re-implementing the // cascade client-side. Surfaced via the X-ZDDC-Canonical-Folder header. func CanonicalFolderAt(fsRoot, dirPath string) string { segs := resolvePathSegments(fsRoot, dirPath) - // <project>/<folder> + // <project>/<folder> — only archive/ is physical at project root. if len(segs) == 2 { - switch segs[1] { - case "archive", "working", "staging", "reviewing": - return segs[1] + if segs[1] == "archive" { + return "archive" } return "" } // <project>/archive/<party>/<folder> if len(segs) == 4 && segs[1] == "archive" { switch segs[3] { - case "incoming", "received", "issued", "mdl": + case "incoming", "received", "issued", "mdl", "rsk", + "working", "staging", "reviewing": return segs[3] } } 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. // Caller's responsibility to check len(chain.Levels) > 0 — but // returns ZddcFile{} on empty for ergonomic chaining. @@ -303,7 +290,7 @@ func isZeroZddcFile(zf ZddcFile) bool { return false } if zf.ReceivedPath != "" || zf.PlannedReviewDate != "" || - zf.PlannedResponseDate != "" || zf.OnPlanReview != nil { + zf.PlannedResponseDate != "" { return false } if len(zf.AvailableTools) > 0 { diff --git a/zddc/internal/zddc/lookups_test.go b/zddc/internal/zddc/lookups_test.go index 40c71bc..29f7ffa 100644 --- a/zddc/internal/zddc/lookups_test.go +++ b/zddc/internal/zddc/lookups_test.go @@ -9,6 +9,11 @@ import ( // TestDefaultToolAt_FromEmbeddedConvention — the canonical default- // tool rules in defaults.zddc.yaml should resolve correctly for the // 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) { resetCache() 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", "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", "received"), "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", "alice@example.com"), "browse"}, - {filepath.Join(root, "Project-X", "staging"), "transmittal"}, + {filepath.Join(root, "Project-X", "staging"), "browse"}, {filepath.Join(root, "Project-X", "reviewing"), "browse"}, } for _, tc := range cases { @@ -45,7 +58,7 @@ func TestDirToolAt(t *testing.T) { // whose default_tool (no-slash form) is something else. for _, p := range []string{ 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", "random", "deep", "folder"), } { @@ -73,8 +86,14 @@ func TestDirToolAt(t *testing.T) { // TestCanonicalFolderAt — structural detection of the canonical // project-layout slots that the browse SPA scope-gates context-menu -// actions against. Top-level <project>/<folder> and second-level -// <project>/archive/<party>/<folder>; everything else returns "". +// actions against. +// +// 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) { resetCache() root := t.TempDir() @@ -83,16 +102,21 @@ func TestCanonicalFolderAt(t *testing.T) { want string }{ {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", "received"), "received"}, {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", "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, ""}, {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", "incoming", "2026-05-15_Acme-0042 (RFI) - Foundation"), ""}, {filepath.Join(root, "Project-X", "random", "dir"), ""}, @@ -107,7 +131,8 @@ func TestCanonicalFolderAt(t *testing.T) { } // 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) { resetCache() root := t.TempDir() @@ -115,13 +140,15 @@ func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) { path string want bool }{ - {filepath.Join(root, "Project-X", "working"), true}, - {filepath.Join(root, "Project-X", "working", "alice@example.com"), true}, - {filepath.Join(root, "Project-X", "staging"), true}, + {filepath.Join(root, "Project-X", "archive", "Acme", "working"), true}, + {filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), 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", "received"), 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", "rsk"), false}, } for _, tc := range cases { got := AutoOwnAt(root, tc.path) @@ -132,9 +159,9 @@ func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) { } } -// TestVirtualAt_FromEmbeddedConvention — mdl/ is declared virtual; -// everything else (including reviewing/, which is now Plan-Review- -// managed with physical workflow folders) materialises on disk. +// TestVirtualAt_FromEmbeddedConvention — mdl/rsk under a party are +// declared virtual, and the six project-level aggregators are virtual. +// Other canonical slots materialise on disk. func TestVirtualAt_FromEmbeddedConvention(t *testing.T) { resetCache() root := t.TempDir() @@ -143,11 +170,19 @@ func TestVirtualAt_FromEmbeddedConvention(t *testing.T) { want bool }{ {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true}, - {filepath.Join(root, "Project-X", "reviewing"), false}, - {filepath.Join(root, "Project-X", "working"), false}, - {filepath.Join(root, "Project-X", "staging"), false}, + {filepath.Join(root, "Project-X", "archive", "Acme", "rsk"), true}, + {filepath.Join(root, "Project-X", "archive", "Acme", "reviewing"), 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", "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 { 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", "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", "reviewing"), true}, + {filepath.Join(root, "Project-X", "ssr"), true}, {filepath.Join(root, "Project-X", "junk"), false}, // not in convention } for _, tc := range cases { @@ -183,17 +221,17 @@ func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) { } } -// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project -// root, the canonical children should be enumerated: the four -// physical folders (archive, working, staging, reviewing) plus the -// three project-level virtual aggregator slots (ssr, mdl, rsk). +// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project root +// the cascade declares archive/ plus the six top-level virtual +// aggregator slots (ssr, mdl, rsk, working, staging, reviewing). func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) { resetCache() root := t.TempDir() got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X")) want := map[string]bool{ - "archive": true, "working": true, "staging": true, "reviewing": true, - "ssr": true, "mdl": true, "rsk": true, + "archive": true, + "ssr": true, "mdl": true, "rsk": true, + "working": true, "staging": true, "reviewing": true, } if len(got) != len(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) { resetCache() 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) } - // Operator declares that Special/working uses classifier - // instead of the embedded-default browse. - writeZddc(t, filepath.Join(root, "Special", "working"), + // Operator declares that Special/archive/Acme/working uses + // classifier instead of the embedded-default browse. + writeZddc(t, filepath.Join(root, "Special", "archive", "Acme", "working"), "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) } // 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) } } @@ -235,8 +273,9 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) { func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) { resetCache() root := t.TempDir() - // Deep path under working/ — not explicitly mentioned in paths:. - deep := filepath.Join(root, "Project-X", "working", "alice@example.com", "notes", "sub", "deep") + // Deep path under archive/<party>/working/ — not explicitly + // 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" { t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)", deep[len(root):], got) @@ -248,7 +287,7 @@ func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) { func TestAutoOwnAt_DescendantCanDisable(t *testing.T) { resetCache() 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 { t.Fatal(err) } @@ -257,7 +296,7 @@ func TestAutoOwnAt_DescendantCanDisable(t *testing.T) { t.Errorf("AutoOwnAt(%q) = %v, want false (descendant override)", deepDir, got) } // 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 { 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")) { 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") } } diff --git a/zddc/internal/zddc/standardroles_test.go b/zddc/internal/zddc/standardroles_test.go index d1c089e..39b0f0f 100644 --- a/zddc/internal/zddc/standardroles_test.go +++ b/zddc/internal/zddc/standardroles_test.go @@ -11,8 +11,13 @@ import ( // - rw at the project level (read + overwrite-existing), but NOT c // (so it can't make arbitrary folders) // - 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 +// +// 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) { resetCache() 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", "issued"), "rc") - // Subtree-admin at working/ and staging/ (via admins: [document_controller] - // in the embedded cascade — role-aware now). - if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working"), Principal{Email: dc, Elevated: true}) { - t.Errorf("doc controller should be subtree-admin of working/") + // Subtree-admin at archive/<party>/ (the embedded cascade + // declares admins: [document_controller] on the party "*" entry, + // so working/staging/reviewing inside the party inherit it). + 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}) { - t.Errorf("doc controller should be subtree-admin of staging/") + if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme", "working"), Principal{Email: dc, Elevated: true}) { + 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}) { - 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. - if !IsSubtreeAdmin(root, filepath.Join(root, "Proj", "working", "alice@example.com"), Principal{Email: dc, Elevated: true}) { - t.Errorf("doc controller (subtree-admin of working/) should reach inside a fenced user home") + // Subtree-admin reaches inside a fenced per-user working home + // under the party's working slot. + 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"] `) // Simulate the auto-own .zddc the file API would write at - // working/alice@example.com/ (fenced via acl.inherit:false, - // creator-owned). - homeDir := filepath.Join(root, "Proj", "working", "alice@example.com") + // archive/Acme/working/alice@example.com/ (fenced via + // acl.inherit:false, creator-owned). + homeDir := filepath.Join(root, "Proj", "archive", "Acme", "working", "alice@example.com") if err := os.MkdirAll(homeDir, 0o755); err != nil { t.Fatal(err) } diff --git a/zddc/internal/zddc/virtualreceived.go b/zddc/internal/zddc/virtualreceived.go index a982817..12a93dd 100644 --- a/zddc/internal/zddc/virtualreceived.go +++ b/zddc/internal/zddc/virtualreceived.go @@ -9,11 +9,11 @@ import ( ) // Virtual `received/` window — the doc-controller's Plan Review composite -// endpoint scaffolds physical folders under <reviewing_root> and -// <staging_root>, each carrying a .zddc whose `received_path:` points back -// at the canonical archive/<party>/received/<tracking>/. When a workflow -// folder is listed, the server injects a synthetic `received/` child that -// shows the canonical submittal's contents in context. +// endpoint scaffolds physical folders under archive/<party>/reviewing/ and +// archive/<party>/staging/, each carrying a .zddc whose `received_path:` +// points back at the canonical archive/<party>/received/<tracking>/. When +// a workflow folder is listed, the server injects a synthetic `received/` +// child that shows the canonical submittal's contents in context. // // Three behaviours rely on this: // @@ -51,7 +51,7 @@ func WorkflowReceivedPath(dirPath string) string { type VirtualReceivedResolution struct { Resolved bool 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) 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") diff --git a/zddc/internal/zddc/virtualviews.go b/zddc/internal/zddc/virtualviews.go index b961678..b60a8f3 100644 --- a/zddc/internal/zddc/virtualviews.go +++ b/zddc/internal/zddc/virtualviews.go @@ -9,15 +9,36 @@ import ( "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 -// `<project>/{ssr,mdl,rsk}`. The folder does not exist on disk: the -// server synthesizes listings by walking archive/*/ at request time -// and rewrites file reads/writes back to canonical paths inside the -// per-party folders. ACL on each synthetic row is evaluated against -// the canonical `<project>/archive/<party>/` chain, so party owners -// can edit their own rows and non-owners see them read-only. +// Six aggregators live at <project>/, all sibling to the only real +// top-level directory archive/. None of them materialise on disk; the +// server synthesises listings by walking archive/*/ at request time +// and (for the tables rollups) rewrites file reads/writes back to +// canonical paths inside the per-party folders. +// +// 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 // @@ -34,6 +55,10 @@ import ( // // /<project>/rsk/ → analogous // +// /<project>/working/ → folder-nav listing (parties with content) +// /<project>/working/<party>/[<rest>] → 302 to /<project>/archive/<party>/working/<rest> +// /<project>/staging/, /<project>/reviewing/ → analogous folder-nav +// // Modeled on virtualreceived.go: one resolver produces canonical // paths; every caller (listing builder, file API rewrite, form // recognizer) reads its policy chain from the canonical path. @@ -52,6 +77,13 @@ const ( VirtualViewRSKRoot VirtualViewRSKSpec VirtualViewRSKRow + // Folder-nav: top-level listing of parties with non-empty + // content in the named lifecycle slot. + VirtualViewFolderNavRoot + // Folder-nav: a per-party URL under one of the folder-nav + // roots. Resolves to a 302 redirect at canonical + // /<project>/archive/<party>/<slot>/<rest>. + VirtualViewFolderNavRedir ) // IsRowKind reports whether k targets a per-party row file (true for @@ -77,15 +109,27 @@ func (k VirtualViewKind) IsSpecKind() bool { // virtual view. func (k VirtualViewKind) IsRootKind() bool { 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 false } // VirtualViewResolution captures the result of mapping a URL onto -// one of the project-level virtual table views. All fields are -// populated only when Resolved is true. +// one of the project-level virtual views. All fields are populated +// only when Resolved is true. type VirtualViewResolution struct { Resolved bool Kind VirtualViewKind @@ -94,7 +138,7 @@ type VirtualViewResolution struct { ProjectURL string // "/<project>/" ProjectAbs string // <fsRoot>/<project> - Slot string // "ssr", "mdl", or "rsk" + Slot string // "ssr", "mdl", "rsk", "working", "staging", "reviewing" SlotURL string // "/<project>/<slot>/" // Populated for VirtualView*Spec kinds: "table.yaml" or "form.yaml". @@ -107,12 +151,18 @@ type VirtualViewResolution struct { CanonicalURL string // /<project>/archive/<party>/... SchemaAbs string // SSR only — <party>/ssr.form.yaml (may not exist; falls back to embedded) RowFilename string // MDL/RSK rollups only — e.g. "D-001.yaml" + + // Populated for VirtualViewFolderNavRedir. The path component + // AFTER the party — empty for /<project>/<slot>/<party>/ itself, + // or the URL-decoded sub-path for deeper URLs. The redirect + // target is /<project>/archive/<party>/<slot>/<RedirRest>. + RedirRest string } // virtualViewRE matches /<project>/<slot>[/<rest>] where slot is one // of the canonical virtual view names. Capture 1 = project, capture // 2 = slot, capture 3 = rest (may be empty). -var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk)(?:/(.*))?$`) +var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk|working|staging|reviewing)(?:/(.*))?$`) // partyNameRE matches the SSR schema's `name` pattern. Same regex // 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) } +// 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 // 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 // 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 + "/" if rest == "" { - switch slot { - case "ssr": - out.Kind = VirtualViewSSRRoot - case "mdl": - out.Kind = VirtualViewMDLRoot - case "rsk": - out.Kind = VirtualViewRSKRoot + if IsFolderNavSlot(slot) { + out.Kind = VirtualViewFolderNavRoot + } else { + switch slot { + case "ssr": + out.Kind = VirtualViewSSRRoot + case "mdl": + out.Kind = VirtualViewMDLRoot + case "rsk": + out.Kind = VirtualViewRSKRoot + } } out.Resolved = true return out } + // Folder-nav slots: any non-empty rest is a per-party redirect + // target. /<project>/working/<party>[/...] → 302 to canonical + // /<project>/archive/<party>/working[/...]. + if IsFolderNavSlot(slot) { + // Split off the party (first segment) from the rest. + party := rest + var redirRest string + if idx := strings.Index(rest, "/"); idx >= 0 { + party = rest[:idx] + redirRest = rest[idx+1:] + } + if !ValidPartyName(party) { + return out + } + out.Party = party + out.PartyArchive = filepath.Join(projectAbs, "archive", party) + out.RedirRest = redirRest + out.CanonicalURL = "/" + project + "/archive/" + party + "/" + slot + "/" + if redirRest != "" { + out.CanonicalURL += redirRest + } + out.Kind = VirtualViewFolderNavRedir + out.Resolved = true + return out + } + if rest == "table.yaml" || rest == "form.yaml" { switch slot { case "ssr": @@ -383,3 +501,52 @@ func ListRollupRows(fsRoot, projectAbs, slot string) ([]VirtualRollupRow, error) }) 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 +} diff --git a/zddc/internal/zddc/virtualviews_test.go b/zddc/internal/zddc/virtualviews_test.go index 989df3d..8c56113 100644 --- a/zddc/internal/zddc/virtualviews_test.go +++ b/zddc/internal/zddc/virtualviews_test.go @@ -121,13 +121,12 @@ func TestResolveVirtualView_NonMatches(t *testing.T) { "/", "/Project", "/Project/", - "/Project/working", "/Project/archive/Acme/mdl", "/Project/ssr/invalid__name__double.yaml", // double-double underscore is rejected "/Project/mdl/__leading.yaml", // empty party - "/Project/mdl/party__.yaml", // empty rowBase - "/Project/ssr/.hidden.yaml", // dotfile party name - "/Project/ssr/0330C1.yaml/sub", // sub-path under row file + "/Project/mdl/party__.yaml", // empty rowBase + "/Project/ssr/.hidden.yaml", // dotfile party name + "/Project/ssr/0330C1.yaml/sub", // sub-path under row file "/Project/notaslot/table.yaml", } for _, url := range cases { @@ -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) { cases := []struct { url string diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index 60f6089..925b3e8 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -100,9 +100,6 @@ func mergeOverlay(base, top ZddcFile) ZddcFile { if top.PlannedResponseDate != "" { out.PlannedResponseDate = top.PlannedResponseDate } - if top.OnPlanReview != nil { - out.OnPlanReview = top.OnPlanReview - } out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools) out.Admins = mergeStringSlice(out.Admins, top.Admins)