User report: project root listings showed both "Archive" (PascalCase on
disk) and "archive (empty)" (lowercase virtual) — confusing duplicates.
This sweep:
1. Test fixture migrated to lowercase canonical folder names.
tests/data/test-archive.sh now creates archive/, received/, issued/
on disk. Three projects also get human-friendly .zddc titles
("Wabash Industrial Refit — Phase 1", etc.), and Project-3 carries
a display: override demonstrating the new map. Party names
(PartyA/B/C) stay unchanged — non-canonical.
2. New .zddc display: schema. Maps a child entry's on-disk name to a
human-friendly label. The on-disk name stays canonical (lowercase
for project-root folders); only the rendered label changes. Match
is case-insensitive. Example:
display:
archive: "Records"
working: "In-Progress"
No upward cascade — a parent .zddc doesn't relabel grand-children;
each directory sets display: on its own children.
3. listing.FileInfo gets a DisplayName field. fs.ListDirectory reads
the directory's .zddc display map and stamps DisplayName per entry.
The field is omitempty so listings without overrides stay
byte-identical to before.
4. Virtual canonical project-root folders (archive/working/staging/
reviewing) are now emitted by zddc-server (fs.ListDirectory) at any
project root where the on-disk variant is absent in any case. This
replaces the client-side injection in browse and lets the display:
map apply to virtual entries the same way it applies to real ones.
Browse drops its withVirtualCanonicals helper; the loader carries
display_name through from the server's listing.
5. Archive app project picker dropdown shows the .zddc title of each
project (sourced from ProjectInfo.Title in the server's project
list), falling back to the folder name when no title is set. When
they differ, the folder name is rendered in muted mono after the
title for traceability. data-name still carries the canonical
folder name so URL state stays stable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related routing fixes:
1. /<project>/archive/<party>/mdl[/] now follows the slash/no-slash
convention uniformly with the rest of the system:
- mdl (no slash) → tables app (default tool for mdl/)
- mdl/ (slash) → browse (ServeDirectory empty-listing fallback)
Previously the slash form auto-redirected to mdl/table.html, which
forced the user into the table view from any party-folder click and
produced a confusing "Unrecognized table URL" error when the
redirect race-conditioned. tableRowsRedirect now only redirects
when a real on-disk table.yaml exists; the default-MDL virtual case
stays in browse via the convention.
New zddc.IsArchivePartyMdlDir helper recognises the canonical
<project>/archive/<party>/mdl pattern at depth 4 (relative path).
fs.ListDirectory uses it to return [] for the missing-on-disk case
so browse renders the empty workspace cleanly. Test updated
(TestServeDirectoryRedirectsDefaultMdl → TestServeDirectoryDefaultMdlNoRedirect).
2. <dir>/.zddc URLs now work at every directory depth.
The dispatcher previously 404'd anything beginning with a dot
(except /.archive and /<dir>/.zddc.html). New IsZddcFileRequest +
ServeZddcFile handlers carve out the raw .zddc leaf so an operator
can navigate to /Project-1/archive/PartyA/mdl/.zddc and inspect
the rules effective at that depth.
Semantics:
- Method: GET / HEAD only. Writes go through the existing admin-
gated form at <dir>/.zddc.html (unchanged).
- ACL: parent directory's read permission gates access; 404
(not 403) is returned to non-readers so existence isn't leaked.
- On disk: file bytes served verbatim with
Content-Type: application/yaml and X-ZDDC-Source: file:<rel>.
- Virtual: when no file exists at this level, a synthetic
placeholder body is returned with a YAML-comment cascade
summary so the reader sees exactly what rules apply here from
ancestors. X-ZDDC-Source: virtual:zddc distinguishes it.
The virtual body parses as valid YAML (`{}` after the comments) so
downstream tooling that consumes the URL isn't confused.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix in fs.ListDirectory was insufficient — main.go's
dispatcher calls os.Stat(absPath) before reaching ServeDirectory,
and 404s on the missing path before the listing code ever runs.
Symptom: GET <project>/working/ on a fresh project still returned
"Not Found" despite the read-side fallback being committed.
Add the same fallback at the dispatcher level: when os.Stat returns
NotExist AND the URL ends with "/" AND the path matches
IsProjectRootFolder, fall through to ServeDirectory rather than
404. ServeDirectory's ACL check + ListDirectory's empty-listing
behavior take it from there.
Separately, fs.ListDirectory now initializes its result slice to
make([]listing.FileInfo, 0) instead of `var result []listing.FileInfo`,
so the JSON encoder emits "[]" rather than "null" for empty
listings — clients (browse, archive) expect an array and choke on
null.
New test TestDispatchEmptyCanonicalProjectFolders covers the four
canonical names (archive/working/staging/reviewing) on a project
where none of them exist on disk yet, plus the negative case (a
non-canonical missing path still 404s).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Listing <project>/{archive,working,staging,reviewing}/ when the folder
doesn't exist on disk now returns an empty 200 listing instead of 404.
The stage-strip nav links into these folders unconditionally; without
this fallback, clicking "Working" against a fresh project (where
working/ hasn't been written to yet) lands on a 404 page rather than
a usable empty view.
Mechanism stays consistent with the existing lazy-folder design:
- GET on missing canonical folder → 200 + empty listing (this commit)
- first WRITE under the same path → EnsureCanonicalAncestors
materialises the on-disk folder + auto-own .zddc
reviewing/ stays virtual-only (in VirtualOnlyCanonicalNames); the
fallback just makes its empty listing always renderable. The future
reviewing/ aggregator (recorded in project memory) will replace the
empty listing with the join-computed virtual entries.
The fallback is gated on IsProjectRootFolder — only depth-2 paths
matching one of the four canonical names. Non-canonical missing paths
still 404 (TestListDirectory_NonCanonicalMissing_StillNotFound).
For working/ specifically the synthetic <viewer-email>/ home entry
still fires from virtualUserHomeEntry, so the user sees their own
placeholder even when working/ doesn't exist yet — first write into
that placeholder triggers the lazy-create chain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ListDirectory now appends a synthetic <viewer-email>/ entry when the
listed path is exactly <project>/working/ (depth 2, case-fold) and no
real directory there matches the viewer's email under any case.
The entry has IsDir=true and a new Virtual=true flag on
listing.FileInfo (omitempty in JSON so existing clients that don't
know the field continue to render it as a regular folder). A first
write to that path materialises a real folder via the existing
auto-own pipeline (EnsureCanonicalAncestors → WriteAutoOwnZddc),
after which subsequent listings drop the synthetic entry naturally.
Anonymous viewers, listings outside working/, and listings inside a
deeper working/ subdirectory all skip the synthetic entry.
Six tests cover: appears-when-missing, suppressed-when-real-exists
(case-fold), anonymous-no-entry, staging/-no-entry, deep-working-no-
entry, and pre-existing-PascalCase-Working/ still triggers it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an internal access-decision boundary that all handlers go through
instead of calling zddc.AllowedWithChain directly. Two implementations
ship:
* InternalDecider — wraps the existing zddc.AllowedWithChain. The
default. No new dependencies, identical semantics to the legacy
code path. ZDDC_OPA_URL=internal (or unset).
* HTTPDecider — POSTs the canonical OPA wire format
(POST /v1/data/zddc/access/allow with {"input": {...}}, response
{"result": true|false}) over HTTP, HTTPS, or a Unix-domain socket.
For federal customers running their own audited Rego policies
alongside zddc-server. ZDDC_OPA_URL=http(s)://… or unix:///….
External-mode failure semantics: unreachable / non-2xx / malformed
response → fail closed (deny) by default with a WARN log. Operators
who prefer availability over correctness flip with ZDDC_OPA_FAIL_OPEN=1.
The decider is constructed once at startup, plumbed through ACLMiddleware
into the request context. Handlers retrieve it via DeciderFromContext;
non-request callers (fs.ListDirectory, EnumerateProjects, enumerateAccess)
take it as an explicit parameter.
zddc.ZddcFile and zddc.ACLRules gain JSON tags so external Rego authors
get idiomatic input shape (acl.allow, admins, …) instead of Go field
names (ACL.Allow, Admins, …).
Test coverage:
* InternalDecider parity tests against zddc.AllowedWithChain (every
documented cascade scenario: empty chain, leaf-allow-wins, leaf-
deny-beats-parent, leaf-allows-what-parent-denies, deepest-match-
wins, etc.)
* HTTPDecider happy-path test (canonical wire format)
* Fail-closed / fail-open / malformed-response tests
Production binary size unchanged (no new deps; HTTP transport is
stdlib net/http). 11 ACL call sites migrated. End-to-end verified
against the worked-example layout in zddc/README.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ZDDC — Zero Day Document Control. A file-naming convention plus five
single-file HTML tools (archive, transmittal, classifier, mdedit,
landing) and an optional Go HTTP server (zddc-server) with ACL and a
virtual archive index. Self-contained, offline-capable, dependency-free.
See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the
build/release/architecture detail, bootstrap/README.md for the
two-level deployment install pattern, and zddc/README.md for the
HTTP server.