ZDDC/zddc/internal/handler/planreview.go
ZDDC db110665f0 feat(server): flat top-level party peers + pure-WORM archive (impl)
Reshape the project layout from "archive/ is the only physical dir + six
virtual aggregators" to a flat set of physical, party-partitioned peers:

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:40:09 -05:00

471 lines
18 KiB
Go

package handler
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"gopkg.in/yaml.v3"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// Plan Review — the doc-controller's "establish the canonical record"
// step. Right-click on archive/<party>/received/<tracking>/ in the
// browse app; the client POSTs X-ZDDC-Op: plan-review with the body
// below.
//
// Authorisation model — no ACL exception, only existing grants:
//
// - Create authority on received/<tracking>/. The doc_controller
// gets this from `worm: [document_controller]` on received/ in the
// cascade defaults; the same `c` (write-once-create) verb that
// lets them file canonical submittals lets them establish this
// .zddc once.
// - 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:
//
// 1. Workflow folders converge first (idempotent — match by
// .zddc.received_path; mkdir if missing; rewrite workflow .zddc
// with received_path + ACL).
// 2. Write received/<tracking>/.zddc — but only if it doesn't exist.
// The .zddc schema is server-constrained to {planned_review_date,
// planned_response_date, created_by} — no ACL, admins, or other
// fields, so this write cannot escalate the invoker's authority.
// If the file already exists, the canonical record is sealed; the
// dates in the request are ignored and the workflow folders are
// converged on top.
//
// So Plan Review's first run establishes the canonical commitment;
// subsequent runs can only re-converge the workflow ACLs (e.g. swap
// review lead). The planned dates are write-once — to change them, an
// admin must edit received/<tracking>/.zddc directly via their admin
// authority (which under the cascade defaults is nobody beneath the
// root admin; deliberate).
const opPlanReview = "plan-review"
// planReviewRequest is the YAML body the browse client POSTs.
type planReviewRequest struct {
ReviewLead string `yaml:"review_lead"`
Approver string `yaml:"approver"`
PlanReviewCompleteDate string `yaml:"plan_review_complete_date"`
PlanResponseDate string `yaml:"plan_response_date"`
}
// planReviewResponse is the JSON returned to the client.
type planReviewResponse struct {
Tracking string `json:"tracking"`
Title string `json:"title"`
Reviewing planReviewFolderOK `json:"reviewing"`
Staging planReviewFolderOK `json:"staging"`
Received planReviewFolderOK `json:"received"`
}
type planReviewFolderOK struct {
Path string `json:"path"`
Created bool `json:"created"`
ZddcWritten bool `json:"zddc_written"`
}
// receivedURLPattern matches /<project>/archive/<party>/received/<tracking>/
// — Plan Review is only valid at that depth. Trailing slash required.
var receivedURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/received/([^/]+)/?$`)
func servePlanReview(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// 1. URL must be a received-tracking folder.
cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/"
m := receivedURLPattern.FindStringSubmatch(cleanURL)
if m == nil {
http.Error(w, "Bad Request — plan-review must POST to /<project>/archive/<party>/received/<tracking>/", http.StatusBadRequest)
return
}
project, party, tracking := m[1], m[2], m[3]
// 2. Body parse.
body, ok := readBodyCapped(cfg, w, r)
if !ok {
return
}
var req planReviewRequest
if err := yaml.Unmarshal(body, &req); err != nil {
http.Error(w, "Bad Request — could not parse YAML body: "+err.Error(), http.StatusBadRequest)
return
}
if req.ReviewLead == "" || req.Approver == "" ||
req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" {
http.Error(w, "Bad Request — body must include review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest)
return
}
resp, status, msg := executePlanReview(cfg, r, project, party, tracking, req)
if status != http.StatusOK {
auditFile(r, "plan-review", cleanURL, status, 0, nil)
http.Error(w, msg, status)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-ZDDC-Source", "fileapi:plan-review")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
auditFile(r, "plan-review", cleanURL, http.StatusOK, 0, nil)
}
// executePlanReview runs the Plan Review three-stage flow against an
// already-resolved received/<tracking>/ path. URL and body parsing
// happen in the caller. Returns the response struct on success;
// non-200 (status, message) on auth or execution failure. The caller
// is responsible for writing the HTTP response.
//
// 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 + "/"
// Hardcoded path convention. The composite endpoint scaffolds a
// submittal folder inside the top-level reviewing/<party>/ and
// staging/<party>/ peers; each carries received_path back to the
// canonical record in archive/<party>/received/<tracking>.
reviewingRoot := filepath.Join(cfg.Root, project, "reviewing", party)
stagingRoot := filepath.Join(cfg.Root, project, "staging", party)
// Pre-flight authorisation. No ACL exception — we use existing
// cascade grants:
// (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
// cascade defaults — the same authority that lets them file
// canonical submittals lets them establish this .zddc once.
p := PrincipalFromContext(r)
email := EmailFromContext(r)
if email == "" {
return nil, http.StatusForbidden, "Forbidden — no authenticated principal"
}
// All three pre-flight checks go through the consolidated decider.
// AllowActionFromChainP routes ActionAdmin .zddc edits and the
// single admin-bypass branch for elevated admins. No manual
// IsAdmin / IsSubtreeAdmin branching here.
decider := DeciderFromContext(r)
for _, root := range []string{reviewingRoot, stagingRoot} {
chain, perr := zddc.EffectivePolicy(cfg.Root, root)
if perr != nil {
return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error()
}
rel, _ := filepath.Rel(cfg.Root, root)
rootURL := "/" + filepath.ToSlash(rel) + "/.zddc"
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, rootURL, policy.ActionAdmin)
if !allowed {
return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks subtree-admin authority for %s",
email, strings.TrimPrefix(root, cfg.Root+string(filepath.Separator)))
}
}
// Verify `c` (create) authority on received/<tracking>/. Elevated
// admins short-circuit inside the decider; non-admin doc_controllers
// come through the WORM-list grant. One code path either way.
{
chain, perr := zddc.EffectivePolicy(cfg.Root, receivedAbs)
if perr != nil {
return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error()
}
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, cleanURL, policy.ActionCreate)
if !allowed {
return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks create authority on %s (filing this submittal requires the doc_controller WORM grant)",
email, strings.TrimPrefix(receivedAbs, cfg.Root+string(filepath.Separator)))
}
}
// Derive a title from received/<tracking>/'s contents — first
// ZDDC-parseable filename's title field wins. Fallback to the
// tracking number itself so the folder name always has a tail.
title := deriveTitleFromReceived(receivedAbs)
if title == "" {
title = tracking
}
// Materialise roots + received/<tracking>/ ancestors (the received
// folder itself was created when the doc controller moved the
// submittal in; defensive ensure here for tests).
for _, root := range []string{reviewingRoot, stagingRoot, receivedAbs} {
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, http.StatusInternalServerError, "Internal Server Error — ensure dirs: " + err.Error()
}
}
// received/<tracking>/.zddc is WRITE-ONCE — the canonical commitment.
// First-run creates it under the invoker's WORM-`c` authority
// (verified above); subsequent runs leave it alone and the request's
// date fields are ignored. The schema is server-constrained: only
// planned_review_date + planned_response_date + created_by are written.
// No ACL, admins, or other content — so this write cannot escalate
// the invoker's authority.
receivedResult, err := establishReceivedPlanDates(receivedAbs, req.PlanReviewCompleteDate, req.PlanResponseDate, email, cfg.Root)
if err != nil {
return nil, http.StatusInternalServerError, "Internal Server Error — received .zddc: " + err.Error()
}
// Converge the workflow folders.
reviewingResult, err := convergeWorkflowFolder(workflowConverge{
fsRoot: cfg.Root,
root: reviewingRoot,
forecast: req.PlanReviewCompleteDate,
tracking: tracking,
title: title,
receivedRel: receivedRel,
acl: map[string]string{req.ReviewLead: "rwcda"},
creatorEmail: email,
})
if err != nil {
return nil, http.StatusInternalServerError, "Internal Server Error — reviewing convergence: " + err.Error()
}
stagingResult, err := convergeWorkflowFolder(workflowConverge{
fsRoot: cfg.Root,
root: stagingRoot,
forecast: req.PlanResponseDate,
tracking: tracking,
title: title,
receivedRel: receivedRel,
acl: map[string]string{req.Approver: "rwcda"},
creatorEmail: email,
})
if err != nil {
return nil, http.StatusInternalServerError, "Internal Server Error — staging convergence: " + err.Error()
}
return &planReviewResponse{
Tracking: tracking,
Title: title,
Reviewing: planReviewFolderOK{
Path: "/" + filepath.ToSlash(reviewingResult.relPath) + "/",
Created: reviewingResult.created,
ZddcWritten: reviewingResult.zddcWritten,
},
Staging: planReviewFolderOK{
Path: "/" + filepath.ToSlash(stagingResult.relPath) + "/",
Created: stagingResult.created,
ZddcWritten: stagingResult.zddcWritten,
},
Received: planReviewFolderOK{
Path: "/" + filepath.ToSlash(receivedResult.relPath) + "/",
Created: receivedResult.created,
ZddcWritten: receivedResult.zddcWritten,
},
}, http.StatusOK, ""
}
// establishReceivedPlanDates writes received/<tracking>/.zddc with the
// committed planned dates iff the file doesn't yet exist. If it does,
// the canonical record is already sealed and the call is a no-op
// (zddcWritten=false in the result); the request's date fields are
// silently ignored on subsequent runs. The schema is server-constrained
// to just the two date fields + created_by — no ACL or admin grants.
func establishReceivedPlanDates(receivedAbs, planReview, planResponse, creatorEmail, fsRoot string) (workflowResult, error) {
var res workflowResult
res.absPath = receivedAbs
if rel, err := filepath.Rel(fsRoot, receivedAbs); err == nil {
res.relPath = filepath.ToSlash(rel)
} else {
res.relPath = receivedAbs
}
zddcPath := filepath.Join(receivedAbs, ".zddc")
if _, err := os.Stat(zddcPath); err == nil {
// Sealed — leave alone. zddcWritten stays false.
return res, nil
} else if !errors.Is(err, os.ErrNotExist) {
return res, err
}
zf := zddc.ZddcFile{
PlannedReviewDate: planReview,
PlannedResponseDate: planResponse,
CreatedBy: creatorEmail,
}
if err := zddc.WriteFile(receivedAbs, zf); err != nil {
return res, err
}
res.zddcWritten = true
res.created = true // first-time establishment
return res, nil
}
// deriveTitleFromReceived scans received/<tracking>/ for ZDDC-parseable
// filenames and returns the first one's title field. Empty if no
// parseable file is found.
func deriveTitleFromReceived(receivedAbs string) string {
entries, err := os.ReadDir(receivedAbs)
if err != nil {
return ""
}
// Sort for deterministic title selection (first alphabetical wins).
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
names = append(names, e.Name())
}
sort.Strings(names)
for _, name := range names {
parsed := zddc.ParseFilename(name)
if parsed.Valid && parsed.Title != "" {
return parsed.Title
}
}
return ""
}
// workflowConverge captures the parameters for converging a single
// reviewing/ or staging/ workflow folder.
type workflowConverge struct {
fsRoot string // master root (cfg.Root) — used to compute response paths
root string // absolute path of reviewing_root or staging_root
forecast string // initial forecast date for the folder name (YYYY-MM-DD)
tracking string // tracking number
title string // derived title
receivedRel string // relative path to canonical submittal, e.g. archive/Acme/received/Acme-0042
acl map[string]string // per-folder ACL grants (principal → verb-set)
creatorEmail string // creator/audit email
}
// workflowResult is the post-convergence summary for one folder.
type workflowResult struct {
relPath string // server-relative path (no leading slash, no trailing slash)
absPath string
created bool // true iff this convergence run mkdir'd the folder
zddcWritten bool // true iff a .zddc was written (always true on success)
}
// convergeWorkflowFolder converges one of the workflow folders (reviewing
// or staging) toward the desired state. Idempotent on re-run.
func convergeWorkflowFolder(c workflowConverge) (workflowResult, error) {
var res workflowResult
// Search the root for an existing folder whose .zddc.received_path
// matches. If found, use it — the user controls the folder name via
// direct rename, so we don't fight their date.
existing, err := findWorkflowFolderByReceivedPath(c.root, c.receivedRel)
if err != nil {
return res, err
}
target := existing
if target == "" {
// No match — mkdir at <root>/<forecast>_<tracking> (TBD) - <title>/.
// Append _2, _3 to disambiguate exact-name collisions with a
// folder belonging to a DIFFERENT submittal.
baseName := sanitiseFolderName(fmt.Sprintf("%s_%s (TBD) - %s", c.forecast, c.tracking, c.title))
candidate := filepath.Join(c.root, baseName)
for n := 2; n <= 100; n++ {
if _, statErr := os.Stat(candidate); errors.Is(statErr, os.ErrNotExist) {
break
} else if statErr != nil {
return res, statErr
}
candidate = filepath.Join(c.root, fmt.Sprintf("%s_%d", baseName, n))
if n == 100 {
return res, fmt.Errorf("convergence: exhausted suffix attempts for %s", baseName)
}
}
if err := os.MkdirAll(candidate, 0o755); err != nil {
return res, fmt.Errorf("mkdir workflow folder: %w", err)
}
target = candidate
res.created = true
}
// Write .zddc with desired content. Overwrites if present. Workflow
// .zddc carries received_path + acl ONLY — no planned dates (those
// live in the canonical received/.zddc, which the sub-admins
// cannot modify).
zf := zddc.ZddcFile{
ReceivedPath: c.receivedRel,
CreatedBy: c.creatorEmail,
}
if len(c.acl) > 0 {
zf.ACL = zddc.ACLRules{Permissions: c.acl}
}
if err := zddc.WriteFile(target, zf); err != nil {
return res, fmt.Errorf("write workflow .zddc: %w", err)
}
res.zddcWritten = true
res.absPath = target
if rel, err := filepath.Rel(c.fsRoot, target); err == nil {
res.relPath = filepath.ToSlash(rel)
} else {
res.relPath = target
}
return res, nil
}
// findWorkflowFolderByReceivedPath scans root for direct children
// whose .zddc has received_path matching the given relative path.
// Returns the matching absolute path, or "" if none.
func findWorkflowFolderByReceivedPath(root, receivedRel string) (string, error) {
entries, err := os.ReadDir(root)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
return "", err
}
want := filepath.ToSlash(filepath.Clean(receivedRel))
for _, e := range entries {
if !e.IsDir() {
continue
}
zddcPath := filepath.Join(root, e.Name(), ".zddc")
zf, perr := zddc.ParseFile(zddcPath)
if perr != nil {
slog.Warn("plan-review: parse workflow .zddc", "path", zddcPath, "err", perr)
continue
}
if zf.ReceivedPath == "" {
continue
}
got := filepath.ToSlash(filepath.Clean(zf.ReceivedPath))
if got == want {
return filepath.Join(root, e.Name()), nil
}
}
return "", nil
}
// sanitiseFolderName replaces filesystem-troublesome characters in a
// title with safe substitutes. Conservative — keeps the ZDDC folder
// grammar (the parens and the " - " separator) intact while taming
// arbitrary user input in the title segment.
func sanitiseFolderName(name string) string {
repl := strings.NewReplacer(
"/", "-",
"\\", "-",
":", "-",
"\x00", "",
)
return strings.TrimSpace(repl.Replace(name))
}