Compare commits

...

11 commits

Author SHA1 Message Date
360049f482 fix(browse): preserve undefined verbs to distinguish Caddy/FS-API from zddc
Three modes again behave consistently after Part 3's per-entry
gating:

  1. file:// (FS Access API picker) — fromHandle leaves verbs unset
     (now undefined, not ""). The events.js Rename/Delete gates
     skip the cap.has cascade check when typeof node.verbs is not
     'string', so the items stay enabled per the original canMutate
     contract.

  2. Caddy file-server — fromServerEntry sees no verbs in the
     listing and preserves undefined. Same skip applies; Rename /
     Delete stay enabled but the underlying server will 405 the
     POST/DELETE (same pre-Part-3 behavior). Markdown/yaml editors
     still mount read-only via cap.has's writable fallback.

  3. zddc-server — verbs is always emitted (possibly as "" for an
     explicit zero grant). cap.has interprets the string and the
     gates apply.

The previous "verbs ?? ''" normalisation collapsed (1)+(2) into the
explicit-zero case, which incorrectly disabled Rename/Delete in
offline mode. Tri-state verbs (string non-empty / string empty /
undefined) restores the intent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:00:12 -05:00
c87dccdb23 docs: client-side capability gating model
Brief subsection under "Permission model" explaining the three
server surfaces that feed front-end gating (verbs in listings,
/.profile/access?path=, missing_verb in 403 bodies) and the shared
client helpers in shared/cap.js. Records the hide/disable
philosophy and notes that transmittal + classifier are FS-API-only
so server-side gating doesn't apply to their UI controls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:51:50 -05:00
defed434cc feat(form): pre-flight Submit gate + cap-toast on 403
Two changes to the form tool's submit path:

  - Submit button hides when /.profile/access?path=<submission dir>
    reports no 'c' verb. The form-status line surfaces a short
    explanation so the user knows why the button disappeared.
  - 403 on POST routes through zddc.cap.handleForbidden, which
    renders an error toast naming the missing verb and offers
    Elevate when the path-scoped view reports an elevation grant
    covering it. The existing "You are not allowed to submit here"
    status line still appears as the in-form indicator.

Also guards shared/cap.js's fetchAccess against file:// URLs —
calling fetch() on a file:// page logs a browser-level error that
shows up as test-runner noise. Short-circuiting to null lets
offline tools (browse on a picked folder, form opened standalone
from a file URL) silently degrade to "no path-scoped info" and
fall back to whatever existing gate they had.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:50:49 -05:00
34208a5bd7 feat(tables): gate +Add row on path verbs.c + cap-toast on 403
Two server-aligned signals on save paths:

  - +Add row button: fetches /.profile/access?path=<current dir> via
    zddc.cap.at() once on load; if path_verbs doesn't include 'c'
    the button disables with a tooltip ("You don't have create
    access in this folder."). Async race-window is the same as any
    other path-scoped fetch — server still gates the POST so a
    stale client gets a 403 toast on click rather than a silent
    accept.

  - 403 on save/create: previously fell into the generic
    "http-error" bucket with a console warn; now branches into
    zddc.cap.handleForbidden which renders an error toast naming the
    missing verb. When the path-scoped view reports an elevation
    grant covering that verb, the toast appends an Elevate button.

Per-row writability stays computed server-side for now — tables
walks rows via FS-API-style handles that don't surface the listing
verbs string. A follow-on pass can switch the row walk to raw
listing entries and gate row.editable on each entry's verbs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:48:02 -05:00
fbfb8d15a1 feat(browse): verb-aware gating on rename/delete + editor save
Browse's row context menu and in-place editors now consult the
server-computed verbs string (via window.zddc.cap.has) before
enabling write/delete affordances:

  - Rename… disables when the entry's verbs lacks 'w'.
  - Delete… disables when verbs lacks 'd'.
  - Markdown editor mounts read-only when verbs lacks 'w'.
  - YAML editor mounts read-only when verbs lacks 'w' for regular
    files, 'a' for the .zddc placeholder (matches the file API's
    ActionAdmin gate at that URL).

Disabled menu items carry a tooltip naming the missing access
("You don't have write access to this item.") so the user discovers
which permission is missing rather than just seeing a greyed row.
shared/context-menu.js gains a `tooltip` field (string or fn(ctx))
that sets the row's title attribute.

canMutate() stays as the source-side gate (server vs FS-API
reachability, zip-member / virtual filtering); verbs gate composes
on top. Server-side ACL still has the final say if a stale client
ever tries the action.

cap.has() falls back to node.writable for 'w' when verbs is absent,
so offline FS-API mode keeps working without a server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:45:11 -05:00
b5b3c92905 feat(shared): cap.js client helpers for permission gating
Three small helpers under window.zddc.cap, wired into every tool's
build:

  cap.at(path)               — Promise<AccessView|null>. Fetches
                               /.profile/access?path=<urlpath> and
                               memoises per-path for the session.
                               Used by tools to gate top-of-page
                               affordances on path_verbs / path_is_admin
                               / path_can_elevate_grant.
  cap.has(node, verb)        — boolean. Reads the listing entry's
                               verbs string for the named verb.
                               Falls back to node.writable for 'w'
                               when verbs is absent (offline FS-API
                               listings or pre-promotion clients).
  cap.handleForbidden(resp,  — parses a 403 response's JSON body for
                  opts)        missing_verb and renders an error
                               toast. When opts.path is supplied AND
                               the path-scoped access view reports
                               path_can_elevate_grant covering the
                               missing verb, the toast appends an
                               "Elevate" button that flips the
                               elevation cookie and reloads.

Browse loader.js + tree.js carry the new verbs field through to the
node objects so context-menu gating can call cap.has(node, 'w'|'d')
without changing the legacy node.writable contract. New CSS rule
.zddc-toast__action styles the inline Elevate button.

Concatenation order: cap.js comes after toast.js + elevation.js so
the dependencies (window.zddc.toast, window.zddc.elevation) are
present at module-load time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:42:05 -05:00
b4a33aa9b3 feat(http): include missing_verb in ACL-deny 403 bodies
ACL-deny sites now write a JSON body naming the missing verb so the
client-side toast can render "you need <verb> here" and offer
elevation (the path-scoped /.profile/access?path= reports whether
elevation would unlock the verb).

Body shape:
  {"error": "Forbidden", "missing_verb": "w"}

New helper writeForbidden(w, action) in errors.go, applied at the
four primary ACL-deny gates:
  - directory.go (list, action=read)
  - fileapi.go (file CRUD; action varies per request)
  - tablehandler.go (table read)
  - archivehandler.go (existence-leak guard, treated as read)

Other 403 sites (no authenticated principal, planreview detail
errors) keep their plain-text bodies — "missing_verb" doesn't apply
there. Existing clients that read the body as text see the JSON
string instead of "Forbidden\n"; no client in this repo parses the
body for content, so it's a non-breaking change in practice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:14:49 -05:00
477c8826a7 feat(profile): path-scoped fields on /.profile/access?path=<url>
Existing /.profile/access stays unchanged when called without ?path=;
the path-scoped fields are populated only when the caller passes a
URL path, so each tool can fetch its root capabilities in one round
trip and gate top-of-page affordances (transmittal Publish, tables
+Add row, browse +New folder) accordingly.

Three new fields (all omitempty so the global shape doesn't change):
  - path_verbs: rwcda subset granted at the requested path under the
    caller's CURRENT elevation state.
  - path_is_admin: subtree-admin authority at the requested path,
    again under current elevation. Distinct from "verbs include 'a'":
    admin authority is WORM-bypass capability, not just .zddc edits.
  - path_can_elevate_grant: verb set the caller would hold AT THIS
    PATH if they elevated — empty when elevation wouldn't change
    anything (already elevated, or no admin grant on chain). Drives
    toast offers like "Elevate to delete this file".

Path resolution mirrors serveProfileEffectivePolicy: must start with
"/", must not escape ZDDC_ROOT. Validation failures leave the fields
empty rather than 400ing — the global view is still useful, and the
client can detect absence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:14:38 -05:00
53a10ab119 feat(listing): per-entry verbs string for client-side capability gating
Add a `verbs` field (canonical "rwcda" subset) to every directory
listing entry, computed via a new
`policy.EffectiveVerbsFromChainP(ctx, d, chain, p, path)` helper that
routes each of the five actions through the decider and unions the
allowed bits — so an external OPA's overrides surface in the wire
field, and active-admin elevation produces the full grant.

Semantics:
  - file entry: verbs from the parent dir's chain (files inherit;
    they have no .zddc of their own). Same chain Writable uses.
  - directory entry: verbs from the subdir's OWN chain, so a fenced
    or extended .zddc inside it shows through.
  - virtual entries (auto-own homes, canonical-folder placeholders,
    workflow received/ window, table.yaml/form.yaml spec rows):
    verbs computed against the would-be path's chain so client
    affordances render correctly before any write materialises a
    real folder.

Writable stays in lockstep with verbs for the transition window so
existing clients (markdown/yaml editor save buttons) keep working
unchanged. Clients should migrate to checking 'w' in verbs and let
Writable wither.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:14:25 -05:00
fb50bb5ef6 feat(roles): add observer standard role
A third standard role for auditors, regulators, and external
read-only viewers. Like project_team it gets project-wide `r`, but
unlike project_team the role itself carries no `c` anywhere — so an
observer can't bring a working/<email>/ home into existence under
auto-own, even though the auto-own mechanism is path-keyed rather
than role-keyed.

Approver-by-design: the role audit explicitly rejects a separate
`approver` role. Plan-Review approval stays with document_controller;
two-person sign-off, when needed, is expressed via per-folder `.zddc`
overrides rather than baked-in roles. Comments in defaults.zddc.yaml
and ARCHITECTURE.md call this out so future role audits don't
reopen the question.

TestStandardRoles_ObserverReadOnlyEverywhere locks the invariants:
project-wide r, no c at archive/incoming/working/staging/reviewing,
WORM zones read-only (no worm-create), and not subtree-admin
anywhere even when notionally elevated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:59:44 -05:00
59b5550872 refactor: nest lifecycle slots per-party + add virtual top-level aggregators
May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:57:45 -05:00
59 changed files with 2239 additions and 810 deletions

View file

@ -287,7 +287,7 @@ The build enforces lockstep mechanically (one command bumps all 8). The rules be
No install script. Two paths:
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor), `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables row-rollups across parties, with a synthesised `$party` source-party column the tables tool renders read-only and strips before write) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party URLs 302-redirect to `archive/<party>/<slot>/`). Mkdir directly at the project root is restricted to `archive` and `_`/`.`-prefixed system names — virtual aggregator names and ad-hoc folders return 409. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
To override at any level, either:
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
@ -489,9 +489,18 @@ roles:
members:
- alice@burnsmcd.com
- '*@acme.com'
observer:
members:
- auditor@regulator.gov
```
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` and `observer: r` project-wide, and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, subtree-admin of every `archive/<party>/` so they own each party's lifecycle slots — `working/`, `staging/`, `reviewing/`, `incoming/`). Populating role members lights all of that up. Plan-Review approval is part of the `document_controller` role by design — there is no separate `approver` role; two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides. The three standard roles' invariants are locked down in `zddc/internal/zddc/standardroles_test.go`.
Pick a role per persona:
- `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`.
- `project_team` — day-to-day contributor. Read across the project; full control of their own `archive/<party>/working/<email>/` home via auto-own with a fenced `.zddc` (`inherit: false`).
- `observer` — pure read-only across the project. No auto-own home (the role itself has no `c` anywhere). Intended for auditors, regulators, and external read-only viewers who must not contribute content.
**Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`):
@ -605,7 +614,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/<party>/incoming/`) and the server's own state dirs (`_app/`, `.zddc.d/tokens/`, `.zddc.d/outbox/`, `.zddc.d/logs/`) are all lowercase by string literal. Operators can drop a `Mixed-Case-Folder/` and it stays mixed-case.
**File and folder names preserve case on disk.** The canonicalization is purely a URL→filesystem-name mapping; nothing renames anything. Lowercase is the *project-wide canonical* convention, and auto-created folders in `internal/zddc/ensure.go` (the per-party `archive/<party>/{working,staging,reviewing,incoming}/`) and the server's own state dirs (`_app/`, `.zddc.d/tokens/`, `.zddc.d/outbox/`, `.zddc.d/logs/`) are all lowercase by string literal. Operators can drop a `Mixed-Case-Folder/` and it stays mixed-case.
**Audit log captures the as-typed path.** `AccessLogMiddleware` snapshots `r.URL.Path` before dispatch rewrites it; the audit record's `path` field is what the client sent. When canonicalization changed it, a `resolved_path` field is added.

View file

@ -679,7 +679,16 @@ The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypa
#### Canonical folders, URL routing & the `.zddc` cascade
There are **no hardcoded folder names** — the canonical project structure (`archive/`, `working/`, `staging/`, `reviewing/`; `archive/<party>/{mdl,incoming,received,issued}/`) is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults.zddc.yaml`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` dumps it; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument.
There are **no hardcoded folder names** — the canonical project structure is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults.zddc.yaml`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` dumps it; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument.
**Project shape (after the May 2026 reshape).** `archive/` is the only physical project-root directory. Everything party-scoped lives uniformly under `archive/<party>/{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working/<email>/, staging/<batch>/, reviewing/<tracking>/}`. Six sibling top-level URLs are **virtual aggregators**, never on disk:
- **Row rollups** (tables tool, `default_tool: tables`) — `<project>/ssr`, `<project>/mdl`, `<project>/rsk`. Synthesise one row per party (SSR) or per row file across parties (MDL/RSK), with the source party injected as a synthesised `$party` column. The `$` sigil marks the column system-managed: the tables tool renders it read-only and strips it before submitting a write. Form-mode "+ Add row" on a rollup view prompts for `party` (the routing key, stored in the form schema as a real input field; stripped on write because the folder name *is* the identity).
- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `<project>/working`, `<project>/staging`, `<project>/reviewing`. List the parties whose `archive/<party>/<slot>/` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `<project>/<slot>/<party>[/<rest>]` 302-redirect to the canonical `<project>/archive/<party>/<slot>[/<rest>]`. No writes through the virtual URL space; sharing/bookmarks land on the canonical path after the redirect.
Mkdir at the project root is restricted: only `archive` and `_`/`.`-prefixed system names are accepted (`handler/fileapi.go: rejectProjectRootMkdir`). Any other name — including the six virtual aggregator names, which would shadow the virtual surface — returns 409 Conflict. This is the only structural mkdir guard; deeper paths are governed by `auto_own:` + `worm:` + ACL.
Plan Review (`X-ZDDC-Op: plan-review`) hardcodes the scaffold convention: workflow folders always land at `<project>/archive/<party>/{reviewing,staging}/<tracking>/`, derived from the originating submittal's path. The pre-reshape `on_plan_review.reviewing_root` / `staging_root` cascade keys were dropped — one convention, no per-project override surface. The `X-ZDDC-On-Plan-Review` response header (set by `directory.go`) lights up on every `/<project>/archive/<party>/received/<tracking>/` URL via the structural `zddc.IsPlanReviewURL` test, so the browse client knows when to show the menu item without re-implementing the cascade.
The schema keys that drive built-in behavior:
@ -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: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor plugin), `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` rollups). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
**Zip-backed directories.** A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON listing of the zip's members (or the browse SPA for an HTML request) and `GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member — so a client navigating a zipped transmittal folder never downloads the whole archive. `GET …/Foo.zip` (no trailing slash) is unchanged: the raw `.zip` download. Read-only: `PUT`/`DELETE`/`POST` to a path inside a `.zip` is rejected (405). ACL is the chain of the directory *containing* the zip — a zip carries no `.zddc` of its own, the same model as the `.archive` virtual surface. Implemented by `internal/zipfs` + `handler.ServeZip`, routed via `splitZipPath` in the dispatcher (before the file-API branch). Offline tools (archive's scanner, browse's tree) get the same capability client-side via `shared/zip-source.js` — a `ZipDirectoryHandle`/`ZipFileHandle` pair over JSZip that mimics the File-System-Access surface. The archive tool treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder; the browse tool expands *any* `.zip`.
@ -704,7 +713,30 @@ The schema keys that drive built-in behavior:
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
**Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of `working/` and `staging/`, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule).
**Standard roles.** `defaults.zddc.yaml` references three roles (all shipped empty — a fresh deployment grants nothing until an operator populates them):
- `document_controller` — read/write across a project, `rwc` at `archive/`, subtree-admin of every `archive/<party>/` and its in-flight slots, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow. Plan-Review approval is part of this role; there is no separate `approver` — two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides rather than baked-in roles.
- `project_team` — read-only across the project; their own `archive/<party>/working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule.
- `observer` — pure read-only across the project. Distinct from `project_team` in that the role itself carries no `c` anywhere, so an observer can't bring a working home into existence under auto-own. Intended for auditors, regulators, and external read-only viewers who must not contribute content.
The role invariants (verb sets at each canonical path, subtree-admin scope) are locked down in `zddc/internal/zddc/standardroles_test.go`. New roles, when added, should ship with a parallel test in that file.
#### Client-side capability gating
Three server surfaces feed the front-end's hide/disable model:
- **Per-entry `verbs` on every directory listing item** (`zddc/internal/listing/types.go`). Canonical `"rwcda"` subset granted to the calling principal at that entry's URL. For files it reflects the parent dir's chain (matches Writable's gate); for directories it reflects the subdir's OWN chain. `Writable` stays in lockstep during the transition window; new clients should read `verbs` and let `writable` wither.
- **`GET /.profile/access?path=<urlpath>`** returns the global view (Email, IsSuperAdmin, CanElevate, …) plus three path-scoped fields: `path_verbs` (verbs at the requested path under the caller's CURRENT elevation), `path_is_admin` (subtree-admin authority at that path under current elevation), and `path_can_elevate_grant` (verbs the caller WOULD hold at that path if they elevated, empty when elevation wouldn't change anything). Each tool fetches its current directory once on load to gate top-of-page affordances.
- **403 ACL-deny responses carry a JSON body** `{"error": "Forbidden", "missing_verb": "<r|w|c|d|a>"}` (`zddc/internal/handler/errors.go writeForbidden`). Other 403 conditions (no authenticated principal, existence-leak guards) keep plain-text bodies — `missing_verb` only applies to ACL denies.
Client side, `shared/cap.js` consumes all three: `zddc.cap.has(node, verb)` reads the listing's verbs string (falling back to `node.writable` for `w` on offline FS-API listings); `zddc.cap.at(path)` memo-fetches the path-scoped profile view; `zddc.cap.handleForbidden(resp, opts)` renders an error toast naming the missing verb and offers an Elevate button when `path_can_elevate_grant` covers it.
Each tool gates per the hide/disable rules:
- **Hide** admin-only actions (`a`), WORM-zone destructive items, and flow-terminal steps (Publish, advance state) when the verb is unattainable.
- **Disable + tooltip** everyday write affordances (Rename/Delete in the context menu, Save in editors, `+ Add row`, `+ New folder`, `Submit`) so the user discovers what permission is missing and can elevate if applicable.
- **Optimistic** for bulk / cross-directory operations — let the server return 403 and surface it via `cap.handleForbidden`.
Browse implements the per-entry gating (rename/delete + editor save); tables and form pre-flight their primary writes via `cap.at` + route 403s through `cap.handleForbidden`. Transmittal and classifier write through the FS Access API rather than the server, so server-side gating doesn't apply to their UI controls.
### File API (authenticated CRUD)

View file

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

View file

@ -64,6 +64,7 @@ concat_files \
"js/app.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"../shared/cap.js" \
> "$js_raw"
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents

View file

@ -55,6 +55,7 @@ concat_files \
"../shared/preview-lib.js" \
"../shared/context-menu.js" \
"../shared/elevation.js" \
"../shared/cap.js" \
"../shared/icons.js" \
"../shared/zddc-source.js" \
"js/init.js" \

View file

@ -895,16 +895,55 @@
{ separator: true },
// ── Rename + Delete (the permission-gated pair) ──
//
// Two gates compose: canMutate() rules out un-writable
// sources (offline FS-API without a handle, zip members,
// virtual placeholders) and — when the listing carries
// server-cascade verbs — zddc.cap.has(node, verb) applies
// the per-entry ACL. The verbs gate is server-mode only;
// file:// FS-API and plain Caddy listings have no verbs
// field, so we fall back to canMutate alone (FS-API
// enforces locally; Caddy has no PUT/DELETE either way).
// Server-side ACL still has the final say on the actual
// PUT/DELETE if a stale client tries the action.
{
label: 'Rename…',
disabled: function (c) { return !canMutate(c); },
disabled: function (c) {
if (!canMutate(c)) return true;
if (!serverMode || !window.zddc.cap) return false;
// verbs===undefined → Caddy or other non-zddc
// server, no cascade signal to gate on. verbs===""
// is zddc-server's explicit zero grant; still
// gate (disable). verbs==="rw…" → check the bit.
if (typeof c.node.verbs !== 'string') return false;
return !window.zddc.cap.has(c.node, 'w');
},
tooltip: function (c) {
if (!serverMode || !canMutate(c)) return '';
if (!window.zddc.cap) return '';
if (typeof c.node.verbs !== 'string') return '';
if (window.zddc.cap.has(c.node, 'w')) return '';
return "You don't have write access to this item.";
},
action: function (c) { renameNode(c.node); }
},
{
label: 'Delete…',
icon: '🗑',
danger: true,
disabled: function (c) { return !canMutate(c); },
disabled: function (c) {
if (!canMutate(c)) return true;
if (!serverMode || !window.zddc.cap) return false;
if (typeof c.node.verbs !== 'string') return false;
return !window.zddc.cap.has(c.node, 'd');
},
tooltip: function (c) {
if (!serverMode || !canMutate(c)) return '';
if (!window.zddc.cap) return '';
if (typeof c.node.verbs !== 'string') return '';
if (window.zddc.cap.has(c.node, 'd')) return '';
return "You don't have delete access to this item.";
},
action: function (c) { deleteNode(c.node); }
},
{ separator: true },

View file

@ -40,8 +40,26 @@
// Server-computed write authority — true if the policy
// decider would allow a PUT for the calling principal.
// Absent / false means "save will 403"; preview editors
// read this to mount in read-only mode.
// read this to mount in read-only mode. Superseded by
// verbs (below); kept in lockstep during the transition.
writable: !!e.writable,
// Server-computed verb set: canonical "rwcda" subset the
// calling principal holds at this entry's URL. Per-entry
// gating in the context menu (Rename/Delete) reads this
// through zddc.cap.has(node, 'w'|'d').
//
// "rw…" — zddc-server emitted explicit grant.
// "" — zddc-server emitted explicit zero grant
// (rare; usually the entry would have been
// filtered before reaching the client).
// undefined — the server didn't emit a verbs field at
// all (Caddy or any non-zddc backend).
// cap.has and the events.js gates treat
// this as "verbs unknown" and skip the
// per-entry cascade gate; canMutate +
// whatever the server enforces on the
// actual PUT/DELETE still apply.
verbs: typeof e.verbs === 'string' ? e.verbs : undefined,
// FS-API specific (null in server mode):
handle: null
};

View file

@ -304,11 +304,14 @@
function canSave(node) {
if (isZipMemberNode(node)) return false;
// Server-computed authority gate. The listing's `writable`
// bit reflects what a PUT would do — false here means the
// file API would 403 the save, so we mount in read-only
// mode rather than letting the user type and lose changes.
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
// Server-computed authority gate. The listing's verbs string
// tells us whether a PUT to this entry would be allowed —
// false here means the file API would 403, so we mount in
// read-only mode rather than letting the user type and lose
// changes. cap.has() falls back to node.writable for 'w'
// when verbs is absent (offline FS-API listings).
if (node.url && window.app.state.source === 'server'
&& window.zddc.cap && !window.zddc.cap.has(node, 'w')) return false;
if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true;
return false;

View file

@ -82,10 +82,15 @@
// user home, canonical-folder virtuals) is just a tree
// affordance, not a writable file.
if (node.virtual && node.name !== '.zddc') return false;
// Server-computed authority gate. Mirrors the markdown editor's
// check — listing's `writable` bit is the same decision the
// file API would reach on PUT.
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
// Server-computed authority gate. The virtual .zddc entry
// requires the admin verb 'a' (matches fileapi.go's
// ActionAdmin gate at the .zddc URL); regular YAML files
// require write 'w'. cap.has falls back to node.writable for
// 'w' when verbs is absent (offline FS-API listings).
if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
var needed = node.name === '.zddc' ? 'a' : 'w';
if (!window.zddc.cap.has(node, needed)) return false;
}
if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true;
return false;

View file

@ -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/<party>/working/<email>/<file> and
// archive/<party>/staging/<batch>/<file>. 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/<transmittal>/ back to the user's
// working/<email>/ home (overridable).
// Stage: move a file from archive/<party>/working/<…> into a
// transmittal folder under archive/<party>/staging/<…>. Modal lists
// existing transmittal folders in the party's staging/ plus a "New
// transmittal folder…" option that prompts for a ZDDC-conforming
// name and mkdirs it before the move.
//
// Unstage: move a file from archive/<party>/staging/<transmittal>/
// back to the user's archive/<party>/working/<email>/ home
// (overridable).
//
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite
// 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
// /<project>/working/<…>. Unstageable if it lives under
// /<project>/staging/<transmittal>/<…>. Both are path-shape
// queries — content/ACL is enforced server-side.
// A file is stageable if its path matches
// /<project>/archive/<party>/working/<…>. Unstageable if it
// matches /<project>/archive/<party>/staging/<transmittal>/<…>.
// Both are path-shape queries — content/ACL is enforced server-
// side.
function projectAndSubtree(path) {
// projectPartySlot returns { project, party, slot, rest } when
// path matches /<project>/archive/<party>/<slot>/<rest…>, or
// null on non-match.
function projectPartySlot(path) {
var rel = path.replace(/^\/+|\/+$/g, '').split('/');
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/<transmittal-folder>/<file> — 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/<party>/staging/<transmittal-folder>/<file> — 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/<party>/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/<party>/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 });

View file

@ -49,7 +49,16 @@
// whether to mount read-only. Dropping the field here
// silently makes every node read-only — the actual root
// cause behind "I'm admin but the editor says read-only".
writable: !!raw.writable
writable: !!raw.writable,
// Server-computed verb set (canonical "rwcda" subset).
// Per-entry permission gating reads this via
// zddc.cap.has(node, verb). Three states:
// "rw…" — zddc-server explicit grant
// "" — zddc-server explicit zero grant
// undefined — Caddy / FS-API listings (no verbs field).
// Per-entry gates skip the cascade check
// and fall back to canMutate / writable.
verbs: typeof raw.verbs === 'string' ? raw.verbs : undefined
};
state.nodes.set(id, node);
return node;

View file

@ -62,6 +62,7 @@ concat_files \
"js/excel.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"../shared/cap.js" \
> "$js_raw"
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents

View file

@ -32,6 +32,7 @@ concat_files \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"../shared/cap.js" \
"js/app.js" \
"js/context.js" \
"js/util.js" \

View file

@ -79,6 +79,29 @@
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.addEventListener('click', app.modules.post.submit);
// Pre-flight gate: hide Submit when the cascade denies
// create at the submission directory. Server still
// enforces on POST — this just avoids dangling an
// affordance that would 403. Submission directory is the
// parent of submitUrl; fall back to the page URL when
// submitUrl is absent (file:// / no-context mode).
if (window.zddc && window.zddc.cap && app.context && app.context.submitUrl) {
const subUrl = app.context.submitUrl;
const dir = subUrl.replace(/\/[^\/]*$/, '/') || subUrl;
window.zddc.cap.at(dir).then(function (view) {
if (!view) return;
const verbs = view.path_verbs || '';
if (verbs.indexOf('c') === -1) {
submitBtn.hidden = true;
const status = document.getElementById('form-status');
if (status) {
status.textContent = "You don't have permission to submit here.";
status.hidden = false;
status.classList.add('is-error');
}
}
});
}
}
}

View file

@ -56,6 +56,12 @@
showStatus('Please correct the errors below.', 'error');
} else if (res.status === 403) {
showStatus('You are not allowed to submit here.', 'error');
if (window.zddc && window.zddc.cap) {
window.zddc.cap.handleForbidden(res, {
context: 'Submit',
path: app.context.submitUrl
});
}
} else if (res.status === 409) {
showStatus('A submission with this filename already exists.', 'error');
} else {

View file

@ -34,6 +34,7 @@ concat_files \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"../shared/cap.js" \
"js/landing.js" \
> "$js_raw"

163
shared/cap.js Normal file
View file

@ -0,0 +1,163 @@
// shared/cap.js — client-side capability helpers for permission gating.
//
// Three small helpers, exposed under window.zddc.cap, that wrap the
// server's verbs / /.profile/access?path / 403 missing_verb surface:
//
// zddc.cap.at(path) — Promise<AccessView|null>. Fetches
// /.profile/access?path=<urlpath> and
// memoises per-path for the session.
// Used by tools to gate top-of-page
// affordances (Publish, +Add row,
// +New folder) on PathVerbs.
// zddc.cap.has(node, verb) — boolean. Reads node.verbs (string
// "rwcda"-subset) for the listed verb.
// Transition: falls back to
// node.writable for 'w' when verbs
// is absent, so the legacy field still
// drives gating on old listings.
// zddc.cap.handleForbidden(resp, opts) — given a 403 fetch Response,
// parses the JSON body for
// missing_verb and renders a toast.
// Offers "Elevate" when the path's
// /.profile/access?path= reports a
// path_can_elevate_grant covering the
// missing verb.
//
// Tools using this module must concat shared/cap.js AFTER shared/
// toast.js (toast dependency) and shared/elevation.js (cookie shape).
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.cap) return;
var pathCache = new Map(); // path → AccessView (or null sentinel)
async function fetchAccess(path) {
// file:// pages have no server to fetch /.profile/access from;
// calling fetch() there logs a browser-level error before our
// catch even runs. Short-circuit so offline tools (browse on
// a picked folder, form opened from a file URL) silently
// degrade to "no path-scoped info, fall back to existing
// gating signals".
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return null;
}
try {
var url = '/.profile/access';
if (path) url += '?path=' + encodeURIComponent(path);
var resp = await fetch(url, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
cache: 'no-cache'
});
if (!resp.ok) return null;
return await resp.json();
} catch (_e) {
return null;
}
}
// at(path) — fetch path-scoped access view, memoised per path
// within the page session. Cache is page-scoped: any elevation
// toggle forces a hard reload (see shared/elevation.js), which
// resets the cache so stale-after-elevation isn't a concern. Pass
// null/undefined for the global view (no ?path=).
async function at(path) {
var key = path || '';
if (pathCache.has(key)) return pathCache.get(key);
var view = await fetchAccess(path);
pathCache.set(key, view);
return view;
}
// has(node, verb) — check a per-entry verbs string for a single
// verb. Verb is a one-character string ('r'|'w'|'c'|'d'|'a').
// Transition shim: when node.verbs is absent, fall back to
// node.writable for 'w' so the legacy field keeps editor save
// buttons working on old listings — drop this fallback once every
// tool's loader sets node.verbs unconditionally.
function has(node, verb) {
if (!node) return false;
if (typeof node.verbs === 'string') {
return node.verbs.indexOf(verb) !== -1;
}
if (verb === 'w' && typeof node.writable === 'boolean') {
return node.writable;
}
return false;
}
// VERB_LABELS — human-readable phrases for the 403 toast. "create"
// covers both new-file PUT and mkdir; "admin" includes .zddc edits.
var VERB_LABELS = {
r: 'read',
w: 'write',
c: 'create',
d: 'delete',
a: 'edit access rules'
};
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
// decide whether to offer an Elevate action. opts.context is an
// optional string prefix shown before the verb message ("Save",
// "Delete", etc.) — purely cosmetic.
//
// Best-effort: when the body isn't JSON or missing_verb is
// absent, falls back to a plain "Forbidden" toast. Returns the
// Promise so callers can await before chaining.
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
try {
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
} catch (_e) { /* non-JSON body */ }
var prefix = opts.context ? (opts.context + ': ') : '';
var verbLabel = VERB_LABELS[missing] || missing || '';
var msg;
if (verbLabel) {
msg = prefix + 'You do not have ' + verbLabel + ' access here.';
} else {
msg = prefix + 'Forbidden.';
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
// action appended to the toast message; clicking sets the
// elevation cookie and reloads, matching the header toggle.
var canOffer = false;
if (opts.path && missing) {
var view = await at(opts.path);
if (view && typeof view.path_can_elevate_grant === 'string'
&& view.path_can_elevate_grant.indexOf(missing) !== -1) {
canOffer = true;
}
}
var toastFn = (window.zddc && window.zddc.toast) || function () {};
var el = toastFn(msg, 'error', { durationMs: 8000 });
if (canOffer && el && el.appendChild) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'zddc-toast__action';
btn.textContent = 'Elevate';
btn.addEventListener('click', function (ev) {
ev.stopPropagation(); // don't dismiss the toast
if (window.zddc.elevation && window.zddc.elevation.setElevated) {
window.zddc.elevation.setElevated(true);
window.location.reload();
}
});
el.appendChild(btn);
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();

View file

@ -9,8 +9,11 @@
//
// `items` is an array (or a function returning an array, evaluated
// against `context` at open-time). Each entry is one of:
// { label, action, icon?, accel?, disabled?, visible?, danger? }
// { label, action, icon?, accel?, disabled?, visible?, danger?, tooltip? }
// — a normal menu item; `action(ctx)` fires on click/Enter.
// `tooltip` (string or fn(ctx)) sets the row's title attribute —
// useful for explaining WHY a disabled item is unavailable
// ("You don't have write access here", etc.).
// { label, checked, action, ... }
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
// a ✓ in the gutter when truthy.
@ -21,10 +24,10 @@
// are collapsed automatically so callers can build items
// conditionally without managing dividers.
//
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may
// be a function — each is invoked with the context object so callers
// can render fully context-aware menus from a single declarative
// config.
// Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and
// `items` may be a function — each is invoked with the context object
// so callers can render fully context-aware menus from a single
// declarative config.
//
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
// submenu, ArrowLeft / Escape backs up one level (or closes if
@ -146,6 +149,10 @@
row.classList.add('is-disabled');
row.setAttribute('aria-disabled', 'true');
}
if ('tooltip' in item) {
var tip = resolve(item.tooltip, ctx);
if (tip) row.title = String(tip);
}
row.setAttribute('role',
hasSub ? 'menuitem'
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));

View file

@ -38,3 +38,22 @@
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* Inline action button appended to a toast by zddc.cap.handleForbidden
when an Elevate path is offered. Stops click propagation on its own
so clicking the button doesn't also dismiss the toast. */
.zddc-toast__action {
display: inline-block;
margin-left: 0.75rem;
padding: 0.25rem 0.75rem;
background: var(--accent, var(--text));
color: var(--bg);
border: none;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.zddc-toast__action:hover {
filter: brightness(1.1);
}

View file

@ -42,6 +42,7 @@ concat_files \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"../shared/cap.js" \
"../shared/context-menu.js" \
"js/mode.js" \
"js/app.js" \

View file

@ -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)

View file

@ -125,6 +125,33 @@
addRowBtn.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') handleAdd(ev);
});
// Permission gate: fetch the path-scoped verbs for the
// current directory and disable + Add row when the
// cascade denies create. Async — the button shows up
// optimistically and disables a tick later if the
// server says no, which is the same race window every
// path-scoped fetch has. Server still gates the POST,
// so the worst case is a 403 toast on click.
if (window.zddc && window.zddc.cap) {
window.zddc.cap.at(location.pathname).then(function (view) {
if (!view) return;
var verbs = view.path_verbs || '';
if (verbs.indexOf('c') === -1) {
addRowBtn.classList.add('is-disabled');
addRowBtn.setAttribute('aria-disabled', 'true');
addRowBtn.title = "You don't have create access in this folder.";
// Swallow clicks so the no-op feedback is the
// tooltip, not a 403 toast on submission.
addRowBtn.addEventListener('click', function (ev) {
if (addRowBtn.classList.contains('is-disabled')) {
ev.preventDefault();
ev.stopPropagation();
}
}, true);
}
});
}
}
}

View file

@ -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) {
@ -306,6 +316,17 @@
return { status: 'invalid', errors: errs };
}
if (resp.status === 403) {
setRowState(rowId, 'errored');
if (window.zddc && window.zddc.cap) {
window.zddc.cap.handleForbidden(resp, {
context: 'Save row',
path: location.pathname
});
}
return { status: 'forbidden' };
}
// Other status — generic error.
console.warn('[tables] save returned', resp.status);
setRowState(rowId, 'errored');
@ -385,6 +406,17 @@
return { status: 'invalid', errors: errs };
}
if (resp.status === 403) {
setRowState(rowId, 'errored');
if (window.zddc && window.zddc.cap) {
window.zddc.cap.handleForbidden(resp, {
context: 'Add row',
path: location.pathname
});
}
return { status: 'forbidden' };
}
console.warn('[tables] createRow returned', resp.status);
setRowState(rowId, 'errored');
return { status: 'http-error', code: resp.status };

View file

@ -52,7 +52,7 @@ test.describe('shared/logo.js', () => {
});
test('wraps with href=/<project> when inside a project subtree', async ({ page }) => {
await page.goto(`${baseUrl}/Project-1/working/casey/notes.md`, { waitUntil: 'load' });
await page.goto(`${baseUrl}/Project-1/archive/Acme/working/casey/notes.md`, { waitUntil: 'load' });
const got = await page.evaluate(() => {
const 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 && {

View file

@ -87,6 +87,7 @@ concat_files \
"js/focus.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"../shared/cap.js" \
"js/main.js" \
> "$js_raw"

View file

@ -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
// /<project>/{working,staging,reviewing}/<party>[/...]
// 302 to /<project>/archive/<party>/<slot>[/...] — the
// canonical physical path. The per-party folder-nav
// virtual itself has no on-disk presence; the redirect
// hands the client off to the real address so subsequent
// navigation, sharing, and bookmarks stay canonical.
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if vv := zddc.ResolveVirtualView(cfg.Root, urlPath); vv.Resolved && vv.Kind == zddc.VirtualViewFolderNavRedir {
target := vv.CanonicalURL
// Preserve trailing slash from the request, since
// the canonical URL is a directory.
if strings.HasSuffix(urlPath, "/") && !strings.HasSuffix(target, "/") {
target += "/"
}
// Preserve query string verbatim — clients
// passing ?hidden=1 etc. should land at the same
// query on the canonical URL.
if q := r.URL.RawQuery; q != "" {
target += "?" + q
}
http.Redirect(w, r, target, http.StatusFound)
return
}
}
// File doesn't exist at this path. Before falling through to
// 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 <project>/{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. <workflow>/received/[...] is a
// synthetic view onto the canonical received/<tracking>/

View file

@ -201,17 +201,18 @@ func TestDispatchAppsResolution(t *testing.T) {
}
// Folder availability rules: classifier should NOT be served at root
// (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/<party>/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/<party>/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 {

View file

@ -37,23 +37,36 @@ func AppAvailableAt(root, requestDir, app string) bool {
// which app to serve at a directory URL with no trailing slash —
// trailing-slash URLs serve the browse app for any directory.
//
// Rules (case-insensitive on canonical folder names):
// Rules (case-insensitive on canonical folder names), under the
// May 2026 reshape:
//
// - <project>/archive/<party>/mdl/... → "tables"
// - <project>/archive/ → "archive"
// - <project>/archive/<party>/... → "archive"
// - <project>/staging/... → "transmittal"
// - <project>/working/... → "browse" (hosts the
// markdown editor plugin)
// - <project>/reviewing/... → "browse" (operates on the
// virtual aggregator listing)
// - any other directory → "" (no default)
// - <project>/archive/<party>/{mdl,rsk}/... → "tables"
// - <project>/archive/<party>/staging/... → "transmittal"
// - <project>/archive/<party>/{working,reviewing}/...
// → "browse" (hosts the
// markdown editor plugin)
// - <project>/archive/<party>/incoming/... → "classifier"
// - <project>/archive/<party>/{received,issued}/...
// → "archive"
// - <project>/archive/ → "archive"
// - <project>/{ssr,mdl,rsk} → "tables" (project-
// level rollup virtuals
// with synthesized
// $party column)
// - <project>/{working,staging,reviewing} → "browse" (project-
// level folder-nav
// virtuals — per-party
// URLs 302 to the
// canonical archive/
// <party>/<slot>/)
// - any other directory → "" (no default)
//
// The mdl rule wins over the broader archive rule because the table
// editor is a more specific surface for browsing planned deliverables
// than the archive index. Note: the dir at archive/<party>/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/<party>/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).

View file

@ -21,13 +21,13 @@ func TestAppAvailableAt(t *testing.T) {
{root, "landing", true},
{root + "/Project-A", "landing", false},
// classifier: working/, staging/, archive/<party>/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 {

View file

@ -133,8 +133,8 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
continue
}
subURLPath := baseURL + name + "/"
allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, subURLPath)
if !allowed {
subVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, subURLPath)
if !subVerbs.Has(zddc.VerbR) {
continue // omit denied directories silently
}
// Pull the title from this subdir's own .zddc, if it has
@ -156,6 +156,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
DisplayName: displayName,
Declared: declared,
Title: title,
Verbs: subVerbs.String(),
}
result = append(result, fi)
continue
@ -172,55 +173,58 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
DisplayName: displayName,
Declared: declared,
}
// Writable surfaces whether THIS principal could PUT this file
// — same decision as the file API's authorizeAction would
// reach. Uses the parent-dir chain (computed once above);
// active-admin status short-circuits the per-file decider
// query when the principal already holds admin authority.
// .zddc requires ActionAdmin (not ActionWrite) so the verb
// matches the file API's gate at fileapi.go:362-364.
action := policy.ActionWrite
if name == ".zddc" {
action = policy.ActionAdmin
}
// Verbs surfaces what the principal can do at this file's URL,
// computed against the parent-dir chain (files inherit from
// parent; they have no .zddc of their own). Writable is the
// legacy single-bit projection — it stays in lockstep with
// the verbs string for the transition window. For .zddc files
// the legacy gate maps Writable to the admin verb (a) instead
// of write (w), matching fileapi.go's ActionAdmin gate at
// the .zddc URL.
fileURL := baseURL + name
if parentActiveAdmin {
fi.Writable = true
} else {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, action)
if allowed {
fi.Writable = true
}
fileVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, fileURL)
fi.Verbs = fileVerbs.String()
writableBit := zddc.VerbW
if name == ".zddc" {
writableBit = zddc.VerbA
}
fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin
result = append(result, fi)
}
// Per-user virtual home: when listing <project>/working/ for an
// authenticated viewer, surface a synthetic <viewer-email>/ entry if
// no real folder of any case variant already exists for them. A
// first write to that path materialises a real folder with auto-own
// .zddc; subsequent listings drop the synthetic entry naturally.
if syn, ok := virtualUserHomeEntry(fsRoot, dirPath, userEmail, baseURL, result); ok {
// Per-user virtual home: when listing
// <project>/archive/<party>/working/ for an authenticated viewer,
// surface a synthetic <viewer-email>/ entry if no real folder of
// any case variant already exists for them. A first write to that
// path materialises a real folder with auto-own .zddc; subsequent
// listings drop the synthetic entry naturally.
if syn, ok := virtualUserHomeEntry(ctx, decider, fsRoot, dirPath, principal, baseURL, result); ok {
result = append(result, syn)
}
// At a project root, surface the 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.
result = append(result, virtualCanonicalFolders(fsRoot, absDir, baseURL, result, displayMap)...)
// 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(ctx, decider, fsRoot, absDir, principal, 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/<party>/ 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/<party>/ 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/<party>/<slot>/ has
// non-empty content (in-flight filter). The browse client
// follows a click through to the virtual URL
// <project>/<slot>/<party>/ which the dispatcher 302s to the
// canonical archive/<party>/<slot>/.
if vv := zddc.ResolveVirtualView(fsRoot, strings.TrimSuffix(baseURL, "/")); vv.Resolved && vv.Kind.IsRootKind() {
partyChains := make(map[string]zddc.PolicyChain)
chainFor := func(partyAbs string) zddc.PolicyChain {
@ -234,22 +238,32 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
appendVirtualRow := func(syntheticName, partyAbs string) {
rowURL := baseURL + url.PathEscape(syntheticName)
chain := chainFor(partyAbs)
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, principal, rowURL); !allowed {
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, rowURL)
if !verbs.Has(zddc.VerbR) {
return
}
partyActiveAdmin := elevated && userEmail != "" &&
zddc.IsAdminForChain(chain, userEmail)
writable := partyActiveAdmin
if !writable {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, chain, principal, rowURL, policy.ActionWrite)
writable = allowed
}
result = append(result, listing.FileInfo{
Name: syntheticName,
URL: rowURL,
IsDir: false,
Virtual: true,
Writable: writable,
Writable: verbs.Has(zddc.VerbW),
Verbs: verbs.String(),
})
}
appendVirtualPartyDir := func(party, partyAbs string) {
dirURL := baseURL + url.PathEscape(party) + "/"
chain := chainFor(partyAbs)
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, dirURL)
if !verbs.Has(zddc.VerbR) {
return
}
result = append(result, listing.FileInfo{
Name: party + "/",
URL: dirURL,
IsDir: true,
Virtual: true,
Verbs: verbs.String(),
})
}
@ -266,12 +280,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, Verbs: zddc.VerbR.String()},
listing.FileInfo{Name: "form.yaml", URL: baseURL + "form.yaml", IsDir: false, Virtual: true, Verbs: zddc.VerbR.String()},
)
}
}
// Workflow folder: append a virtual `received/` entry whose backing
@ -290,12 +315,22 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
}
}
if !hasReal {
result = append(result, listing.FileInfo{
Name: "received/",
URL: baseURL + "received/",
IsDir: true,
Virtual: true,
})
receivedURL := baseURL + "received/"
// Verbs against the canonical workflow's chain — the
// virtual `received/` resolves to a read-through window
// onto received/<tracking>/; writes go through serveFilePut
// which rewrites to a +Cn revision. Read is the only verb
// surfaced here.
vrVerbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, receivedURL)
if vrVerbs.Has(zddc.VerbR) {
result = append(result, listing.FileInfo{
Name: "received/",
URL: receivedURL,
IsDir: true,
Virtual: true,
Verbs: (vrVerbs & zddc.VerbR).String(),
})
}
}
}
@ -327,7 +362,8 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
// chain, short-circuited when the principal already holds admin
// authority. An elevated admin sees writable=true and the editor lets
// them save; a non-admin sees writable=false and the editor mounts
// read-only.
// read-only. Verbs carries the full verb set so a client can also gate
// other affordances (e.g. delete on the editor's toolbar).
func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain zddc.PolicyChain, principal zddc.Principal, parentActiveAdmin bool, absDir, baseURL string) (listing.FileInfo, bool) {
zddcPath := filepath.Join(absDir, ".zddc")
if _, err := os.Stat(zddcPath); err == nil {
@ -335,17 +371,14 @@ func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain z
} else if !os.IsNotExist(err) {
return listing.FileInfo{}, false
}
writable := parentActiveAdmin
if !writable {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc", policy.ActionAdmin)
writable = allowed
}
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, parentChain, principal, baseURL+".zddc")
return listing.FileInfo{
Name: ".zddc",
URL: baseURL + ".zddc",
IsDir: false,
Virtual: true,
Writable: writable,
Writable: verbs.Has(zddc.VerbA) || parentActiveAdmin,
Verbs: verbs.String(),
}, true
}
@ -357,8 +390,10 @@ func virtualZddcEntry(ctx context.Context, decider policy.Decider, parentChain z
// incoming, received, issued under archive/<party>/; whatever an
// operator added via on-disk .zddc paths:). Case-insensitive
// presence check suppresses a virtual entry when the on-disk
// directory exists in any case.
func virtualCanonicalFolders(fsRoot, absDir, baseURL string,
// directory exists in any case. Verbs are computed against each
// synthetic child's would-be chain so client-side gating matches
// what a real on-disk folder would carry.
func virtualCanonicalFolders(ctx context.Context, decider policy.Decider, fsRoot, absDir string, principal zddc.Principal, baseURL string,
real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo {
declared := zddc.ChildrenDeclaredAt(fsRoot, absDir)
@ -380,34 +415,54 @@ func virtualCanonicalFolders(fsRoot, absDir, baseURL string,
if present[strings.ToLower(name)] {
continue
}
childAbs := filepath.Join(absDir, name)
chain, err := zddc.EffectivePolicy(fsRoot, childAbs)
if err != nil {
continue
}
childURL := baseURL + url.PathEscape(name) + "/"
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, childURL)
if !verbs.Has(zddc.VerbR) {
continue
}
synth = append(synth, listing.FileInfo{
Name: name + "/",
URL: baseURL + url.PathEscape(name) + "/",
URL: childURL,
IsDir: true,
Virtual: true,
DisplayName: lookupDisplay(displayMap, name),
Declared: true, // synthesized entries are by definition cascade-declared
Verbs: verbs.String(),
})
}
return synth
}
// virtualUserHomeEntry returns the synthetic <viewer-email>/ 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
// <project>/archive/<party>/working/<email>/ (depth-4 working slot
// inside the party folder). The synthetic entry fires when dirPath
// case-folds to <project>/archive/<party>/working and the viewer has
// no real home folder yet.
//
// Conditions for the entry to fire:
// - dirPath case-folds to <project>/working at depth-2 of fsRoot
// - dirPath case-folds to <project>/archive/<party>/working at
// depth-4 of fsRoot
// - viewerEmail is non-empty
// - real does not already contain a directory entry that case-folds
// to viewerEmail (so a materialised home doesn't get duplicated)
func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) {
if viewerEmail == "" {
func virtualUserHomeEntry(ctx context.Context, decider policy.Decider, fsRoot, dirPath string, principal zddc.Principal, baseURL string, real []listing.FileInfo) (listing.FileInfo, bool) {
if principal.Email == "" {
return listing.FileInfo{}, false
}
rel := strings.Trim(filepath.ToSlash(dirPath), "/")
parts := strings.Split(rel, "/")
if len(parts) != 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 {
@ -416,15 +471,31 @@ func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []l
}
// fi.Name carries a trailing slash for dirs.
bare := strings.TrimSuffix(fi.Name, "/")
if strings.EqualFold(bare, viewerEmail) {
if strings.EqualFold(bare, principal.Email) {
return listing.FileInfo{}, false
}
}
// Compute verbs against the would-be home's own chain — the
// auto_own_fenced declaration in defaults.zddc.yaml means a real
// home grants the creator rwcda; the synthetic entry reports the
// same so client-side gating renders the "+ New" affordances
// immediately, before the first write materialises the folder.
homeAbs := filepath.Join(fsRoot, filepath.FromSlash(dirPath), principal.Email)
chain, err := zddc.EffectivePolicy(fsRoot, homeAbs)
if err != nil {
return listing.FileInfo{}, false
}
homeURL := baseURL + url.PathEscape(principal.Email) + "/"
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, homeURL)
if !verbs.Has(zddc.VerbR) {
return listing.FileInfo{}, false
}
return listing.FileInfo{
Name: viewerEmail + "/",
URL: baseURL + url.PathEscape(viewerEmail) + "/",
Name: principal.Email + "/",
URL: homeURL,
IsDir: true,
Virtual: true,
Verbs: verbs.String(),
}, true
}

View file

@ -21,14 +21,20 @@ func setupTreeRoot(t *testing.T) string {
return root
}
// Per-user homes now live at archive/<party>/working/<email>/ (depth-
// 4). The virtual entry fires when listing that path for a viewer
// whose home doesn't yet exist on disk.
func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
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/<email>/ — 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/<party>/.
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 <project>/working/ etc. unconditionally; this keeps
// fresh projects (no working/ on disk yet) from 404'ing.
// nav links into <project>/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 <viewer-email>/ entry; the others
// should be a flat empty listing.
// working/ surfaces a synthetic <viewer-email>/ 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,135 @@ 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)
}
}
// TestListDirectory_VerbsPerEntry — every entry in a directory listing
// carries `verbs`, the canonical "rwcda" subset granted to the caller
// at that entry's URL. Files and dirs are gated against different
// chains (files use parent's, dirs use their own), so a fenced subdir
// surfaces a different verb set than its file siblings.
func TestListDirectory_VerbsPerEntry(t *testing.T) {
root := t.TempDir()
// Root grants alice read across the project; bob nothing.
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n permissions:\n \"alice@example.com\": rw\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "Proj", "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Proj", "doc.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
// Subdir extends alice's grant to include create — confirms the
// dir entry's verbs come from its OWN chain, not parent's.
if err := os.WriteFile(filepath.Join(root, "Proj", "sub", ".zddc"),
[]byte("acl:\n permissions:\n \"alice@example.com\": rwc\n"), 0o644); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root,
"Proj", "alice@example.com", "/Proj/", false, false)
if err != nil {
t.Fatalf("list: %v", err)
}
wantVerbs := map[string]string{
"doc.md": "rw", // file: parent chain (project root → rw)
"sub/": "rwc", // dir: own chain (extends to rwc)
}
for _, fi := range got {
want, ok := wantVerbs[fi.Name]
if !ok {
continue
}
if fi.Verbs != want {
t.Errorf("entry %s verbs = %q, want %q", fi.Name, fi.Verbs, want)
}
// Writable stays in lockstep with verbs for the transition
// window — w bit for files, r/c semantics for dirs (no
// Writable on dirs today; we don't assert it).
if !fi.IsDir {
wantWritable := want == "rw"
if fi.Writable != wantWritable {
t.Errorf("entry %s Writable = %v, want %v", fi.Name, fi.Writable, wantWritable)
}
}
}
}
// TestListDirectory_VerbsActiveAdminBypass — an elevated admin sees the
// full "rwcda" verb set on every entry regardless of explicit ACL
// grants. Mirrors the InternalDecider's single bypass branch.
func TestListDirectory_VerbsActiveAdminBypass(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("admins:\n - admin@example.com\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "Proj", "doc.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
// Elevated admin sees rwcda everywhere.
got, err := ListDirectory(context.Background(), nil, root,
"Proj", "admin@example.com", "/Proj/", false, true /* elevated */)
if err != nil {
t.Fatalf("list: %v", err)
}
for _, fi := range got {
if fi.Verbs != "rwcda" {
t.Errorf("elevated admin %s verbs = %q, want rwcda", fi.Name, fi.Verbs)
}
}
// Same admin un-elevated sees nothing (no explicit ACL grant,
// admin bypass disabled).
got, err = ListDirectory(context.Background(), nil, root,
"Proj", "admin@example.com", "/Proj/", false, false)
if err != nil {
t.Fatalf("list un-elevated: %v", err)
}
for _, fi := range got {
if fi.Verbs == "rwcda" {
t.Errorf("un-elevated admin %s verbs = %q, should not be full grant", fi.Name, fi.Verbs)
}
}
}

View file

@ -142,7 +142,7 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW
// non-empty bucket, 403 — never confirm the project's archive
// exists to a caller with no permissions in it.
if len(result) == 0 {
http.Error(w, "Forbidden", http.StatusForbidden)
writeForbidden(w, policy.ActionRead)
return
}

View file

@ -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 {

View file

@ -4,21 +4,23 @@
#
# This view aggregates every deliverable row from every party under
# <project>/archive/. Each synthetic row is backed by the real file
# at <project>/archive/<party>/mdl/<file>.yaml; the leading `party`
# column is derived from the row's source folder (path-injected by
# the server, not stored in the YAML).
# at <project>/archive/<party>/mdl/<file>.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 <project>/archive/<party>/ 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 <project>/archive/<party>/ 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/<party>/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 }

View file

@ -3,21 +3,23 @@
#
# This view aggregates every risk row from every party under
# <project>/archive/. Each synthetic row is backed by the real file
# at <project>/archive/<party>/rsk/<file>.yaml; the leading `party`
# column is derived from the row's source folder (path-injected by
# the server, not stored in the YAML).
# at <project>/archive/<party>/rsk/<file>.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 <project>/archive/<party>/ 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 <project>/archive/<party>/ 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/<party>/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 }

View file

@ -78,7 +78,7 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
isRoot := dirPath == ""
if !isRoot {
if allowed, _ := policy.AllowFromChainP(ctx, decider, chain, PrincipalFromContext(r), urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
writeForbidden(w, policy.ActionRead)
return
}
}
@ -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 /<project>/archive/<party>/received/<tracking>/, which is
// the only shape the handler accepts. Browse uses the header to
// show/hide the "Plan Review" right-click menu item without
// 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/<party>/{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

View file

@ -0,0 +1,54 @@
package handler
import (
"encoding/json"
"net/http"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
)
// writeForbidden emits a 403 JSON response naming the missing verb. Used
// at every ACL-deny site so the client-side toast can render a specific
// "you need <verb> here" message and offer elevation when the path-scoped
// /.profile/access?path= reports a would_elevate_grant covering that verb.
//
// Body shape:
//
// {"error": "Forbidden", "missing_verb": "w"}
//
// Existing clients that read the body as text see the JSON string instead
// of "Forbidden\n" — both are diagnostic-only display strings, no client
// in this repo parses the previous plain-text body for content. Used in
// place of `http.Error(w, "Forbidden", http.StatusForbidden)` exclusively
// for ACL-deny cases. Other 403 conditions (no authenticated principal,
// existence-leak guards, etc.) keep the plain-text variant since
// "missing_verb" doesn't apply to them.
func writeForbidden(w http.ResponseWriter, action string) {
verb := verbForAction(action)
body, _ := json.Marshal(map[string]string{
"error": "Forbidden",
"missing_verb": verb,
})
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write(body)
}
// verbForAction maps a policy.Action constant to its single-character
// verb. Mirrors policy.actionVerb but emits the wire-format letter
// rather than the bitmask, so the JSON body carries "r"/"w"/"c"/"d"/"a"
// — the same alphabet the listing's `verbs` field uses.
func verbForAction(action string) string {
switch action {
case policy.ActionWrite:
return "w"
case policy.ActionCreate:
return "c"
case policy.ActionDelete:
return "d"
case policy.ActionAdmin:
return "a"
default:
return "r"
}
}

View file

@ -156,7 +156,7 @@ func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request,
decider := DeciderFromContext(r)
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
if !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
writeForbidden(w, action)
return false
}
return true
@ -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 <project>/ is `archive` (plus _/.-prefixed
// system names). Mkdir of any other name — including the six
// virtual aggregator names (ssr/mdl/rsk/working/staging/reviewing)
// — is rejected with 409, because the virtual would shadow any
// physical folder created at the same URL.
if rejected, why := rejectProjectRootMkdir(cfg.Root, abs); rejected {
http.Error(w, why, http.StatusConflict)
return
}
// Resolve canonical-folder casing on the way in (no side effects).
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/<party>/staging/<batch>/ and working at
// archive/<party>/working/<email>/, the project-level pairing
// no longer maps cleanly. Operators who want a per-batch drafting
// space create it inside their own working/<email>/ 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 <project>/staging/. Best
// effort — failures are logged but do not affect the staging mkdir result.
// rejectProjectRootMkdir reports whether a mkdir at abs lands at
// <project>/<name>/ where <name> is forbidden as a direct project-
// root physical child. Under the canonical layout:
//
// Eligibility:
// - newAbs's parent is exactly <project>/staging/ (case-fold)
// - filepath.Base(newAbs) parses as a transmittal folder
// (YYYY-MM-DD_<tracking> (<status>) - <title>)
// - 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.

View file

@ -5,9 +5,9 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
@ -147,6 +147,54 @@ func TestFileAPI_PutDenyForbidden(t *testing.T) {
if rec.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
}
// 403 body carries JSON with the missing verb so the client toast
// can render "you need <verb> here" and offer elevation when the
// path-scoped /.profile/access reports an elevation grant. PUT to
// a path with no existing file is gated on `c` (create); a PUT
// over an existing file would gate on `w` instead — covered by
// the test below.
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var body struct {
Error string `json:"error"`
MissingVerb string `json:"missing_verb"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v (raw: %s)", err, rec.Body.String())
}
if body.MissingVerb != "c" {
t.Errorf("missing_verb = %q, want c (PUT to non-existing file gates on create)", body.MissingVerb)
}
}
// TestFileAPI_PutDenyForbiddenOverwriteVerb — PUT over an existing file
// gates on the write verb, so 403 reports missing_verb=w. Mirrors
// TestFileAPI_PutDenyForbidden but with a seeded file.
func TestFileAPI_PutDenyForbiddenOverwriteVerb(t *testing.T) {
cfg, do, _ := fileAPITestSetup(t, nil, map[string]string{
"Working/seeded.md": "before",
})
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"),
[]byte("acl:\n permissions:\n \"*@allowed.com\": rwcd\n"), 0o644); err != nil {
t.Fatalf("rewrite .zddc: %v", err)
}
zddc.InvalidateCache(cfg.Root)
rec := do(http.MethodPut, "/Working/seeded.md", "alice@example.com", []byte("after"), nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
}
var body struct {
MissingVerb string `json:"missing_verb"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode 403 body: %v", err)
}
if body.MissingVerb != "w" {
t.Errorf("missing_verb = %q, want w (PUT over existing file gates on write)", body.MissingVerb)
}
}
func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) {
@ -306,15 +354,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 +375,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 +384,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 +716,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.)

View file

@ -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

View file

@ -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))
}

View file

@ -62,7 +62,7 @@ func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.R
case "/", "":
serveProfilePage(cfg, w, r)
case "/access":
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r)))
writeJSON(w, enumerateAccess(r.Context(), DeciderFromContext(r), cfg, PrincipalFromContext(r), r.URL.Query().Get("path")))
case "/projects":
serveProfileProjectsCreate(cfg, w, r)
case "/whoami":
@ -150,6 +150,31 @@ type AccessView struct {
CanCreateProject bool `json:"can_create_project"`
Projects []ProjectInfo `json:"projects"`
AdminSubtrees []treeEntry `json:"admin_subtrees"`
// Path-scoped fields. Populated only when the caller passes
// ?path=<url-path> on the request. Empty when the global view
// (no ?path=) was requested, so the existing global-shape clients
// keep their wire format unchanged.
//
// PathVerbs is the canonical "rwcda" subset granted to the caller
// at the requested path under their CURRENT elevation state. A
// top-of-tool affordance (transmittal's Publish, tables' +Add row,
// browse's +New folder toolbar) reads this once on load and gates
// itself accordingly.
//
// PathIsAdmin reports whether the caller has subtree-admin
// authority at the requested path, again under current elevation.
// Distinct from "verbs include 'a'": admin authority is the WORM-
// bypass capability, not just .zddc edit access.
//
// PathCanElevateGrant is the verb set the caller would hold AT
// THIS PATH if they elevated — empty when elevation would change
// nothing (already elevated, or no admin grant on the chain).
// Drives toast offers like "Elevate to delete this file" without
// the client second-guessing the cascade.
PathVerbs string `json:"path_verbs,omitempty"`
PathIsAdmin bool `json:"path_is_admin,omitempty"`
PathCanElevateGrant string `json:"path_can_elevate_grant,omitempty"`
}
// enumerateAccess builds an AccessView for the given caller. Used by the
@ -158,7 +183,13 @@ type AccessView struct {
// view after first paint. The principal carries elevation: an un-elevated
// admin reports IsSuperAdmin=false here, so the UI naturally renders the
// non-elevated view (no admin scaffolds shown) until the user opts in.
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal) AccessView {
//
// pathQuery is the optional ?path=<url-path> query value — when non-empty
// the path-scoped fields (PathVerbs, PathIsAdmin, PathCanElevateGrant) are
// populated so a single fetch answers both "what can I do globally" and
// "what can I do at this URL". An invalid or escape-attempting path is
// silently ignored (the global fields still return).
func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal, pathQuery string) AccessView {
view := AccessView{
Email: p.Email,
EmailHeader: cfg.EmailHeader,
@ -179,9 +210,50 @@ func enumerateAccess(ctx context.Context, decider policy.Decider, cfg config.Con
allowed, _ := policy.AllowActionFromChainP(ctx, decider, rootChain, p, "/", policy.ActionCreate)
view.CanCreateProject = allowed
}
if pathQuery != "" {
populatePathScopedAccess(ctx, decider, cfg, p, pathQuery, &view)
}
return view
}
// populatePathScopedAccess fills the PathVerbs / PathIsAdmin /
// PathCanElevateGrant fields by walking the cascade at pathQuery and
// running the decider for each verb under (1) the caller's actual
// elevation and (2) a hypothetical elevated principal. Path resolution
// mirrors serveProfileEffectivePolicy: must start with "/", must not
// escape ZDDC_ROOT. Validation failures leave the fields empty rather
// than 400ing — the global view is still useful, and the client can
// detect absence.
func populatePathScopedAccess(ctx context.Context, decider policy.Decider, cfg config.Config, p zddc.Principal, pathQuery string, view *AccessView) {
if !strings.HasPrefix(pathQuery, "/") {
return
}
rel := strings.TrimPrefix(pathQuery, "/")
rel = strings.TrimSuffix(rel, "/")
absDir, ok := safeJoin(cfg.Root, rel)
if !ok {
return
}
chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
if err != nil {
return
}
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery)
view.PathVerbs = verbs.String()
view.PathIsAdmin = p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email)
// would_elevate_grant: only meaningful when (a) the caller isn't
// already elevated and (b) elevation would actually change the
// verb set. Avoid noise — an empty value tells the client there
// is nothing to offer.
if !p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email) {
elevatedP := zddc.Principal{Email: p.Email, Elevated: true}
ifElevated := policy.EffectiveVerbsFromChainP(ctx, decider, chain, elevatedP, pathQuery)
if ifElevated != verbs {
view.PathCanElevateGrant = ifElevated.String()
}
}
}
// enumerateAdminSubtrees lists every directory containing a .zddc that the
// caller can see as an admin (super-admin or subtree-admin). Every entry
// is editable — subtree admins own their own .zddc. Returns empty for an

View file

@ -487,6 +487,115 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
}
}
// TestServeProfileAccessPathScoped — /.profile/access?path=<url> answers
// "what can the caller do at this URL" alongside the global view. Three
// flavors cover the cases the toast/menu gating cares about:
//
// - non-admin caller with explicit ACL grant: PathVerbs reflects the
// grant; PathIsAdmin=false; PathCanElevateGrant empty (elevation
// wouldn't change anything for a non-admin).
// - un-elevated admin: PathVerbs reflects the explicit grant (no
// admin bypass yet); PathIsAdmin=false; PathCanElevateGrant carries
// the full "rwcda" elevation would unlock.
// - elevated admin: PathVerbs="rwcda" (admin bypass active);
// PathIsAdmin=true; PathCanElevateGrant empty (nothing to upgrade).
func TestServeProfileAccessPathScoped(t *testing.T) {
root := t.TempDir()
// Root admins list — sudo authority for admin@example.com (when
// elevated). Permissions grant alice rw at the project level.
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(`admins:
- admin@example.com
acl:
permissions:
"alice@example.com": rw
`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "Proj"), 0o755); err != nil {
t.Fatal(err)
}
zddc.InvalidateCache(root)
zddc.InvalidateScanCache()
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
ring := NewLogRing(50)
fetch := func(email string, elevated bool) AccessView {
t.Helper()
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec,
requestAsUserMaybeElevated(http.MethodGet, "/.profile/access?path=/Proj/", email, elevated))
if rec.Code != http.StatusOK {
t.Fatalf("email=%q elevated=%v status=%d body=%s", email, elevated, rec.Code, rec.Body.String())
}
var v AccessView
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatalf("decode email=%q: %v", email, err)
}
return v
}
// Non-admin caller with explicit grant: verbs reflect the ACL,
// no admin status, no elevation offer.
alice := fetch("alice@example.com", false)
if alice.PathVerbs != "rw" {
t.Errorf("alice PathVerbs = %q, want rw", alice.PathVerbs)
}
if alice.PathIsAdmin {
t.Errorf("alice PathIsAdmin = true, want false")
}
if alice.PathCanElevateGrant != "" {
t.Errorf("alice PathCanElevateGrant = %q, want empty (no admin grant on chain)", alice.PathCanElevateGrant)
}
// Un-elevated admin: bypass not active, so explicit verbs are
// whatever ACL granted (here: nothing — admin@ has no permissions
// entry, only an admins: entry). PathCanElevateGrant tells the
// client "elevation would unlock rwcda".
adminUn := fetch("admin@example.com", false)
if adminUn.PathVerbs != "" {
t.Errorf("un-elevated admin PathVerbs = %q, want empty (no explicit grant)", adminUn.PathVerbs)
}
if adminUn.PathIsAdmin {
t.Errorf("un-elevated admin PathIsAdmin = true, want false")
}
if adminUn.PathCanElevateGrant != "rwcda" {
t.Errorf("un-elevated admin PathCanElevateGrant = %q, want rwcda", adminUn.PathCanElevateGrant)
}
// Elevated admin: full bypass — verbs rwcda, PathIsAdmin true,
// no elevation offer (already elevated).
adminEl := fetch("admin@example.com", true)
if adminEl.PathVerbs != "rwcda" {
t.Errorf("elevated admin PathVerbs = %q, want rwcda", adminEl.PathVerbs)
}
if !adminEl.PathIsAdmin {
t.Errorf("elevated admin PathIsAdmin = false, want true")
}
if adminEl.PathCanElevateGrant != "" {
t.Errorf("elevated admin PathCanElevateGrant = %q, want empty (already elevated)", adminEl.PathCanElevateGrant)
}
}
// TestServeProfileAccessNoPathQuery — without ?path=, the global view
// works unchanged: path-scoped fields are absent, every existing
// global field is populated.
func TestServeProfileAccessNoPathQuery(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, nil, rec, requestAsAdmin(http.MethodGet, "/.profile/access", "alice@example.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d", rec.Code)
}
var v AccessView
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatalf("decode: %v", err)
}
if v.PathVerbs != "" || v.PathIsAdmin || v.PathCanElevateGrant != "" {
t.Errorf("global view should not include path-scoped fields; got PathVerbs=%q PathIsAdmin=%v PathCanElevateGrant=%q",
v.PathVerbs, v.PathIsAdmin, v.PathCanElevateGrant)
}
}
// TestServeProfileEffectivePolicy: admin queries the cascade tracer for a
// (path, email) tuple and gets back the resolved chain plus the decision.
// The fixture mirrors the worked-example layout from zddc/README.md (a

View file

@ -386,7 +386,7 @@ func ServeTable(cfg config.Config, req *TableRequest, w http.ResponseWriter, r *
slog.Warn("table: policy error", "path", req.Dir, "err", err)
}
if allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, r.URL.Path, policy.ActionRead); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
writeForbidden(w, policy.ActionRead)
return
}

View file

@ -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) {

View file

@ -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)

View file

@ -60,5 +60,30 @@ type FileInfo struct {
// false-or-unknown and gate writes accordingly. Read-only-by-
// default is the safer client-side fallback if the server forgets
// to populate it.
//
// Superseded by Verbs (which carries the full verb set); kept
// alongside it for the transition window so existing clients
// reading writable: don't break. New clients should read Verbs and
// check for 'w'.
Writable bool `json:"writable,omitempty"`
// Verbs is the canonical "rwcda" subset granted to the calling
// principal at this entry's URL. Computed by running the policy
// decider for each of the five actions and unioning the allowed
// bits, with the same active-admin bypass that Writable uses.
//
// Semantics per entry kind:
// - file: verbs from the parent directory's chain (files have no
// .zddc of their own; they inherit). Same chain Writable uses.
// - directory: verbs from the subdirectory's OWN chain, so a
// fenced/extended .zddc inside it shows through. The client
// interprets per-kind: `w` on a dir = "I can rename this
// folder", `c` on a dir = N/A at this URL (use path-scoped
// /.profile/access?path= for "can I create inside this dir").
//
// omitempty: empty set ("") is the explicit-deny case; absence
// means the server didn't populate it. Clients should treat both
// as "no permissions known" and fall back to a server round-trip
// (or just disable affordances) rather than assuming any grant.
Verbs string `json:"verbs,omitempty"`
}

View file

@ -395,6 +395,25 @@ func AllowActionFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChai
return d.Allow(ctx, in)
}
// EffectiveVerbsFromChainP returns the verb set the principal effectively
// holds at path under chain. Routes each verb through the decider so an
// external OPA's overrides surface in the result; with the InternalDecider
// the answer is the cascade's effective grant plus WORM composition plus
// the active-admin bypass. Output is the same wire shape the client
// receives in listing entry verbs / /.profile/access?path= responses.
func EffectiveVerbsFromChainP(ctx context.Context, d Decider, chain zddc.PolicyChain, p zddc.Principal, path string) zddc.VerbSet {
if p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email) {
return zddc.VerbAll
}
var verbs zddc.VerbSet
for _, action := range []string{ActionRead, ActionWrite, ActionCreate, ActionDelete, ActionAdmin} {
if allowed, _ := AllowActionFromChainP(ctx, d, chain, p, path, action); allowed {
verbs |= actionVerb(action)
}
}
return verbs
}
// cachingDecider wraps another Decider with a small per-decision cache.
// Designed for the external-OPA hot path: a single .archive listing or
// directory enumeration can hit the same (email, dir-policy) tuple

View file

@ -22,7 +22,7 @@ acl:
# ── Standard roles ─────────────────────────────────────────────────────────
#
# Two roles ship empty (no members) — a fresh deployment grants
# Three roles ship empty (no members) — a fresh deployment grants
# nothing until an operator populates them. They're referenced by the
# project-scoped grants in paths: below.
#
@ -33,24 +33,38 @@ 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. Plan-Review approval is part of
# this role by design — there is no separate `approver` role;
# two-person sign-off, when needed, is expressed via per-folder
# `.zddc` overrides rather than baked-in roles.
#
# project_team — everyone working on a project. Read-only across
# the project. 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.
#
# observer — pure read-only across the project. Like project_team
# but with no auto-own home: an observer who somehow created a
# working/<email>/ would still own it via auto-own (the mechanism
# is path-keyed, not role-keyed), but since observer lacks `c`
# anywhere, the situation doesn't arise in practice. Intended for
# auditors, regulators, and external read-only viewers who must
# not contribute content.
roles:
document_controller:
members: []
project_team:
members: []
observer:
members: []
# Universal tool baseline. archive (record browser), browse (file
# tree, hosts the in-place markdown editor), and landing (project
@ -89,17 +103,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.
@ -107,27 +136,56 @@ available_tools: [archive, browse, landing]
paths:
# First segment under root is the project name; "*" matches any.
"*":
# Project-scoped baseline ACL. project_team gets read across the
# project; document_controller gets read + overwrite-existing
# (so people can ask them to fix a stuck file). Neither gets
# `c` (create) at this level — that's granted only at the
# specific spots below (archive/, working/, staging/), so the
# doc controller can't make arbitrary folders. Grants here cap
# Project-scoped baseline ACL. project_team and observer get read
# across the project; document_controller gets read + overwrite-
# existing (so people can ask them to fix a stuck file). None of
# the three gets `c` (create) at this level — that's granted only
# at the specific spots below (archive/, working/, staging/), so
# the doc controller can't make arbitrary folders. Grants here cap
# at deeper levels per deepest-match-wins, except where a deeper
# .zddc restates a fuller grant for the same principal.
acl:
permissions:
project_team: r
observer: 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 +203,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 +317,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

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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")
}
}

View file

@ -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)
}
@ -126,3 +140,69 @@ created_by: alice@example.com
t.Errorf("alice in incoming/ = %q, want r (no create/write for project_team)", got.String())
}
}
// TestStandardRoles_ObserverReadOnlyEverywhere — observer is the
// project-wide read-only role for auditors / regulators / external
// viewers. Unlike project_team, an observer must not contribute
// content anywhere: no create at archive/, no create at working/,
// no worm-create at received/issued, and not subtree-admin of
// anything. Read passes through WORM zones (worm: lists strip w/d/a
// but never r).
func TestStandardRoles_ObserverReadOnlyEverywhere(t *testing.T) {
resetCache()
root := t.TempDir()
writeZddc(t, root, `roles:
observer:
members: ["auditor@example.com"]
`)
obs := "auditor@example.com"
mustVerbs := func(dir string, want string) {
t.Helper()
chain, err := EffectivePolicy(root, dir)
if err != nil {
t.Fatalf("EffectivePolicy(%q): %v", dir, err)
}
// Mirror InternalDecider.Allow's WORM-aware composition so the
// assertion covers received/issued correctly.
var got VerbSet
if g, inWorm := WormZoneGrant(chain, obs); inWorm {
got = (EffectiveVerbs(chain, obs) & VerbR) | (g & VerbsRC)
} else {
got = EffectiveVerbs(chain, obs)
}
if got.String() != want {
t.Errorf("observer verbs at %s = %q, want %q", dir[len(root):], got.String(), want)
}
}
// Project level: read-only.
mustVerbs(filepath.Join(root, "Proj"), "r")
// A random subfolder under the project still read-only.
mustVerbs(filepath.Join(root, "Proj", "random-folder"), "r")
// archive/ — read-only (no create at the party-folder level).
mustVerbs(filepath.Join(root, "Proj", "archive"), "r")
// incoming/ — read-only (no create even though incoming/ has
// drop_target and auto_own; the cascade ACL still gates create).
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "incoming"), "r")
// In-flight lifecycle slots — read-only.
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "working"), "r")
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "staging"), "r")
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "reviewing"), "r")
// WORM zones — read passes through; no worm-create (observer is
// not in the worm: list).
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "r")
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "r")
// Observer is not subtree-admin of anything in the project — even
// when notionally elevated, the role carries no admin grant.
if IsSubtreeAdmin(root, filepath.Join(root, "Proj"), Principal{Email: obs, Elevated: true}) {
t.Errorf("observer should NOT be subtree-admin of the project root")
}
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive"), Principal{Email: obs, Elevated: true}) {
t.Errorf("observer should NOT be subtree-admin of archive/")
}
if IsSubtreeAdmin(root, filepath.Join(root, "Proj", "archive", "Acme"), Principal{Email: obs, Elevated: true}) {
t.Errorf("observer should NOT be subtree-admin of archive/<party>/")
}
}

View file

@ -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")

View file

@ -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
}

View file

@ -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

View file

@ -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)