Phase 3 + 4 live two-instance smoke tests against the synthetic
~/zddc-test-data fixture surfaced three real bugs that the unit
tests missed. All three are fixed in this commit.
1. walker: filenames with spaces/parens land on disk percent-encoded
walkSubtree was passing the URL-encoded child URL (built via
url.PathEscape) to fetchFileIfNeeded → cachePathFor, so a file
named "Foo (IFI) - Bar.md" landed at <root>/.../Foo%20%28IFI%29
%20-%20Bar.md on disk. Then purgeOrphans iterated os.ReadDir
(which sees the encoded names) and compared against upstreamNames
(decoded names from the listing JSON). Every fetched file was
classified as an orphan and immediately deleted: a 180-file walk
produced "fetched=180 purged=111" with only 70 files remaining.
Fix: walker now maintains two parallel path strings — dirURL
(URL-encoded for HTTP requests) and dirPath (decoded for disk
keys). fetchFileIfNeeded, fetchListing, persistOnly, and
purgeOrphans all take the decoded path. listingCachePathFor
gets dirPath too. Smoke confirmed: dirs=29 files=180 fetched=179
purged=0 (one file already cached from the user's GET that
triggered the walk).
2. outbox: replay loop sleeps 5min after eager startup pass
RunReplayLoop's idle-poll interval is 5min. After the eager
startup pass with 0 entries, the loop sleeps 5min — even if a
PUT-while-offline arrives 1 second later, replay won't fire for
~5 min. The cache returned 202 promptly but the queued write sat
on disk until either a 5min nap elapsed or another PUT happened.
Fix: Outbox gains a wake chan (buffered=1, drop-on-full).
Enqueue posts to it after writing meta.json. RunReplayLoop selects
on wake alongside the timer, so a new offline write triggers an
immediate replay attempt. Smoke confirmed: PUT queued at T+0,
master back at T+3, replay completes at T+3 (was previously a
30s wait through the timer-based poll).
3. master: PUT/DELETE didn't honor If-Unmodified-Since
The cache's outbox sends If-Unmodified-Since: <cached-mtime> on
replay so the master can reject conflicting writes with 412. The
master's checkIfMatch only evaluated If-Match (ETag-based), so
the cache's mtime-based precondition was silently ignored. Result:
an offline PUT staged before an external mod would clobber the
newer external content on replay — silent data loss in the exact
scenario the outbox is designed to detect.
Fix: checkIfMatch now also evaluates If-Unmodified-Since per
RFC 7232 §3.4, returning 412 when the file's current mtime is
strictly later than the header value (1-second resolution to
match HTTP-Date precision). Smoke confirmed: cache GET → external
mod via direct file write → cache offline PUT → master back →
replay sends IUS → master 412 → outbox entry renamed to
<id>.conflict-<RFC3339>/ → master content preserved (the
external mod, not the stale offline write).
Also added an info-level "outbox: replay attempt" log to tryReplay
so an operator watching the cache logs sees the replay loop is
alive even when every entry defers (transport error). Previously
the loop was silent unless a replay actually completed (200) or
conflicted (412).
go vet + go test ./... + go test -race ./internal/{cache,auth,handler}/...
all green. Synthetic ~/zddc-test-data fixture (553 files, 144 PDFs)
exercises the walker against realistic ZDDC filenames including
spaces, parens, and accented characters that the unit tests'
"a.txt" / "b.txt" inputs never hit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generates a realistic ZDDC archive layout for end-to-end testing of
master + cache + mirror, with zero identifying data and a script
that creates and clears on demand.
Output: ~/zddc-test-data (default; override via TEST_ARCHIVE_DIR),
intentionally OUTSIDE the repo. Defensive .gitignore entries cover
in-repo redirects and the source-reference CSV (~/archive-export*.csv,
which the script never reads at runtime — distributions are baked in
here as constants extracted from a one-time inspection).
Layout mirrors a real archive's shape (project → Archive → party →
Received|Issued → dated transmittal folder → tracking-numbered file)
with synthetic codes throughout — Project-1/2/3, PartyA/B/C, FAC1-4,
lorem-ipsum titles, example.com emails. Disciplines, doc-type codes,
status codes (IFR/IFI/IFA/IFU/RSB), revision letters (A/B/0/0A/0B/C/D),
and tracking-number format are kept as-is — they're public ZDDC
convention vocabularies, not identifying data.
Each file's content is the metadata block:
Tracking Number: <synthetic>
Revision: <letter>
Status: <code>
Title: <lorem-ipsum>
rendered into the appropriate format per extension. Open any file and
verify it's the right one — md as a table, yaml as keys, html as a
styled table, .zddc as YAML, .zip with three views (md+yaml+html), pdf
rendered via docker.io/pandoc/latex (already-existing 563MB image)
through podman with --userns=keep-id so output is host-user-owned.
Falls back to a hand-rolled minimal valid PDF (Python stdlib only)
when podman or the pandoc image is unavailable.
Subcommands:
build [--small] Generate the fixture. --small produces ~12 files,
full produces ~550 with every one of the six
extensions (md/yaml/pdf/html/zddc/zip) guaranteed
in every transmittal.
clear rm -rf the fixture. Refuses unless target contains
a .zddc — defense against an accidental misconfigured
TEST_ARCHIVE_DIR pointing at something important.
info File count, total size, by-extension breakdown,
top-level layout. No content snippets.
POSIX sh (dash-compatible). Randomness via /dev/urandom (no $RANDOM;
dash doesn't expose it). Per-directory .zddc ACL configs use synthetic
emails from RFC-2606 example.com.
Verified: full fixture builds in ~3min (PDF generation dominates),
contains 144 PDFs all valid 1-page, no real-archive tokens leak
(grep -i for known sentinels from the source CSV returns zero hits).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New chart helm/zddc-server-cache/ deploys zddc-server in client mode
against an upstream master. Mirrors the prod chart's source-build-via-
init-container pattern but with:
- ZDDC_UPSTREAM, ZDDC_MODE, ZDDC_BEARER_FILE, ZDDC_NO_AUTH,
ZDDC_SKIP_TLS_VERIFY, ZDDC_MIRROR_SUBTREE, ZDDC_MIRROR_MIN_INTERVAL
wired from values.yaml. Mirror-only env vars conditionally rendered
(only when mode=mirror) to keep the rendered manifest minimal.
- Bearer token mounted from a separately-created Kubernetes Secret
(defaultMode 0400) at /etc/zddc/bearer/token. values.yaml.example
documents the secret-creation flow but contains no token. Secret
reference can be set to "" to disable bearer auth (only valid for
upstreams running --no-auth).
- Recreate strategy + replicaCount: 1 (multiple replicas would race
the cache directory and double the upstream walker traffic).
- TCP-socket probes instead of HTTP — HTTP probes against / would
fail when both upstream is unreachable AND the cache is empty
(the cache layer returns 503 + offline header in that state),
causing crashloops. TCP verifies process liveness without depending
on upstream reachability or cache contents.
- Mounts a separate cache PVC (operator-provided, like the master's
data PVC). Sized to the working set you expect to mirror; can be
much smaller than the master's data volume.
Existing prod and dev charts gain optional ZDDC_NO_AUTH wired from
zddc.env.noAuth (default false → no change to existing rendered
manifests). Useful for trusted-LAN or genuinely-public master
deployments.
Updated docs: helm/README.md gains the cache row in the chart table,
the cache-install quickstart with the secret-creation flow, and the
cache-specific structural notes (Recreate / TCP probes / single-
instance). CLAUDE.md and ARCHITECTURE.md updated to reflect three
charts instead of two.
Verified with helm template rendering: ZDDC_NO_AUTH only renders
when noAuth: true; ZDDC_MIRROR_SUBTREE / ZDDC_MIRROR_MIN_INTERVAL
only render when mode: mirror; bearer volume + ZDDC_BEARER_FILE
only render when bearer.secretName is non-empty.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PUT / POST / DELETE in client mode now work end-to-end. Online: the
cache layer forwards to upstream and (on success) drops any cached
entry for the path so the next read fetches fresh. PUT/DELETE include
If-Unmodified-Since derived from the cached file's mtime so the master
can reject conflicting writes with 412 Precondition Failed.
When upstream is unreachable, the request is captured in the outbox
at <root>/.zddc-outbox/<id>/ — directory per queued write, mode 0700,
containing meta.json (method, RawURI, Content-Type, base mtime,
queued-at) and body.bin (request body, capped at 256 MiB). The client
gets 202 Accepted + X-ZDDC-Cache: queued and a JSON envelope.
A background replay loop started by runClient processes the queue:
- 2xx → delete entry; drop cached path so next read fetches fresh
- 412 → rename to <id>.conflict-<RFC3339>/ for manual reconciliation
(body + meta intact for inspection or re-submit)
- 4xx other → drop (retry won't help; logged at WARN)
- 5xx / transport error → leave for next pass
Replay schedule: eager at startup, then 30s while pending falling
back to 5min while idle. Loop honors graceful-shutdown context.
Disabled in --mode=proxy (proxy persists nothing by design — offline
writes return 503 instead of queueing).
Outbox IDs are <unix-nano-base16>-<hex-random> so lex-sort = queue
order; concurrent enqueues never collide. Conflict-rename appends a
4-char random suffix on the unlikely same-second collision.
The local cache is intentionally not updated for offline writes:
until upstream confirms the user reads still see the upstream-cached
version (or 503 if uncached). Trade-off: no "did my queued write
actually win?" ambiguity, at the cost of not seeing one's own
offline edits immediately. Phase 5 will surface .conflict-<ts>/
directories in browse views.
Tests (20 new in outbox_test.go, 5 new in cache_test.go covering
the write path): NewOutbox creates 0700 dir, Enqueue persists meta
+ body, Pending returns lex-sorted entries excluding conflicts,
Replay deletes on 2xx / renames on 412 / leaves on transport error
/ leaves on 5xx / drops on 4xx-other, IUS sent only for PUT/DELETE
with base mtime, query string preserved, ServeHTTP online write
forwards + evicts cache, ServeHTTP offline write queues with 202,
ServeHTTP offline + no outbox returns 503, ServeHTTP PUT sends IUS
from cached mtime, oversize body rejected, IDs lex-sortable,
RunReplayLoop stops on context cancel, concurrent Enqueue 30×
no collisions. Full suite + go vet clean.
Doc updates: zddc/README.md gains a "Writes (online + offline
outbox)" subsection covering both paths and replay outcomes;
"What client mode is NOT, yet" now lists only conflict UI and
multi-tenancy. AGENTS.md client-mode pipeline gains writes +
mirror-mode bullets. ARCHITECTURE.md adds a "Writes: outbox +
offline replay" subsection with the trade-off rationale and the
phase-5-deferred conflict UI hand-off.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
--mode mirror layers an access-triggered walker on top of the cache
pipeline. When an incoming request's URL falls under one of the
configured --mirror-subtree paths, the scheduler kicks off a recursive
walk of that subtree iff (a) no walk for that subtree is in flight and
(b) now - last_walk_at >= --mirror-min-interval (default 1h). Walks
run in a goroutine; the user's request never blocks on scheduling.
Why access-triggered: a naive "walk on a fixed timer" would produce
thundering-herd polls on a master from many vendor mirrors most of
which are idle most of the time. Demand-triggering means idle mirrors
generate zero upstream traffic until someone hits them; active
mirrors stay current as a side effect of normal use.
The walk:
1. Recursively fetches JSON listings under the subtree, persisting
each at <dir>/.zddc-listing.json so directory browsing works
offline for walked subtrees.
2. For each file, fires a conditional If-Modified-Since GET (bounded
parallelism; default 4 concurrent) — 304 no-op, 200 overwrites,
403/404 purges the local cache.
3. After enumeration, per-directory orphan purge: local files absent
from upstream's filtered listing are removed (handles upstream
deletes + ACL revocations).
State persists at <root>/.zddc-mirror-state.json as
{subtrees: {<path>: {last_walk_at}}}. In-flight tracking is in-memory
only — a crash mid-walk lets the next access retry without manual
cleanup. Subtree path matching is longest-prefix-wins; "/" is a
catch-all (full mirror, the default when --mode=mirror is set without
explicit --mirror-subtree).
The cache layer also gained directory-listing caching (independent of
mirror mode but enabled by it). Directories are now stored at
<dir>/.zddc-listing.<html|json> sidecars, varied by Accept header.
Hit/miss/offline semantics mirror the file pipeline. Phase 2's
limitation that directories always proxied live (no offline browse)
is now resolved for any directory the user has visited or that mirror
mode has walked.
Mirror scope falls out of auth: the walker uses the local instance's
bearer, so it sees exactly what the user can see at upstream. Admin
bearer → full mirror; vendor bearer → vendor's permitted subtree;
no code distinguishes the cases.
New flags (also as ZDDC_* env vars), ignored when --mode != mirror:
- --mirror-subtree <csv> — repeatable subtrees (comma-separated);
empty + --mode=mirror = "/" (full mirror)
- --mirror-min-interval <duration> — default 1h
Tests (15 new in walker_test.go, 3 new in cache_test.go): subtree
normalization, longest-prefix matching, root-as-catch-all, walk
fetches all files in scope, out-of-scope URLs are no-op, rate-
limiting prevents double-walks within min-interval, walks re-fire
after interval elapses, orphan purge removes local-only files,
state file survives restart, concurrent triggers don't double-walk,
end-to-end ServeHTTP-kicks-mirror-on-access, listing format varies
by Accept, listing offline serves stale, persisted state atomic
write + corrupt-input handling. Full suite + go vet clean.
Doc updates: zddc/README.md flags table gains the two new entries
plus a "Mirror mode (access-triggered subtree walker)" subsection
with trigger semantics and properties; the "What client mode is NOT,
yet" list shrinks accordingly. AGENTS.md env-var table gains the
two new entries. ARCHITECTURE.md "Master + proxy/cache/mirror"
section now documents the walker scheduler / walk algorithm / state
file in a "Mirror walker (access-triggered)" subsection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
zddc-server can now run as a downstream client of another zddc-server.
Set --upstream <url> and the master-side machinery (archive index, apps
server, watcher, OPA decider, ACL middleware, token store) is bypassed
entirely; cmd/zddc-server/main.go short-circuits to runClient(cfg)
which uses zddc/internal/cache/Cache as the entire request handler.
Three modes via --mode <proxy|cache|mirror>:
- proxy: forward upstream live, no disk persistence
- cache (default): persist responses on access; subsequent hits serve
from disk + background If-Modified-Since revalidate
- mirror: accepted but currently behaves like cache; the access-
triggered walker lands in phase 3
Cache directory layout is intentionally a normal ZDDC root: a file
fetched from <master>/foo/bar.txt is stored at <root>/foo/bar.txt with
no sidecar metadata. The local file's mtime is set to the upstream's
Last-Modified header so revalidation reflects the master's notion of
file age, not local fetch time. Running zddc-server --root <cache-dir>
without --upstream serves the cached files as a plain master — useful
for portable offline snapshots. A small .zddc-upstream marker is
written once on first persist for provenance.
Pipeline (GET/HEAD only — writes deferred):
- Hit → http.ServeContent serves directly (range-aware, 304-aware) +
background revalidate (304 no-op, 200 overwrite, 403/404 purge)
- Miss → forward to upstream with the configured bearer; tee response
body to client + tmp-file atomically renamed into the cache
- Network error + cached → serve stale + X-ZDDC-Cache: offline
- Network error + no cache → 503 + X-ZDDC-Cache: offline
- Directories always proxy live (no listing cache yet — phase 3)
- Cache-Control: no-store / private and non-200 responses bypass cache
Range requests work end-to-end (Range/If-Range headers forwarded on
miss; http.ServeContent handles them natively on hit). Hop-by-hop
headers per RFC 7230 §6.1 are dropped from forwarded responses.
New flags (also as ZDDC_* env vars), all ignored when --upstream is
empty (so master deployments are untouched):
- --upstream <url>
- --mode proxy|cache|mirror (default cache)
- --bearer-file <path> (0600 file with the master-issued token)
- --skip-tls-verify (separate from --no-auth; for self-signed dev)
Validation: --upstream must be http(s)://...; trailing / is trimmed.
Mode validated to one of the three known values. The startup
no-root-.zddc check is skipped in client mode (the cache directory
starts empty by design). The plain-HTTP-on-non-loopback check is also
skipped (the local instance never reads the email header to decide
anything; auth is forwarded to upstream as a Bearer).
Tests: zddc/internal/cache/cache_test.go runs httptest.NewServer as
the upstream and covers miss-then-hit, proxy-mode-no-persist,
directory-never-cached, HEAD-no-body, offline-with-cache,
offline-no-cache → 503, bearer forwarding, query-string preservation,
no-store bypass, path-traversal rejection, error-status forwarding,
revalidate-on-403/404/200/304, range-on-hit, concurrent-same-URL,
cache-path boundary cases. 23 new tests, full suite + go vet clean.
Live two-instance smoke verified: master at 127.0.0.1:18443, client
at :18444 with --mode cache, miss→hit→hit transitions work, file
materialises under cache root with parent dirs created, marker file
written once, range-on-hit returns 206, master sees background 304s
on every hit, killing master leaves cached files serving from disk
and never-cached files returning 503 + offline header.
Doc updates: zddc/README.md gains a "Client mode" section with the
modes table, flag reference, pipeline summary, two-instance recipe,
and explicit list of phase-2 limitations; AGENTS.md adds the four
new env vars to the reference table and a "Client mode" subsection
with smoke-test recipe and a pointer to the cache package;
ARCHITECTURE.md adds "Master + proxy/cache/mirror" before "Bearer
token issuance," covering the topology, the persist/warm switches,
the cache-IS-a-ZDDC-root invariant, the request pipeline, and the
v1-out-of-scope multi-tenancy note; CLAUDE.md's zddc/ entry
expanded to mention both deployment shapes so future agents pick it
up by default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
zddc-server now issues its own bearer tokens for non-browser callers
(CLI tools, scripts, downstream proxy/cache/mirror instances). No
external IDP, no JWKS rotation. Self-service flow: sign in via the
browser, visit /.tokens, click "Create token," paste the resulting
plaintext into a 0600 file, and pass --bearer-file <path> to whatever
calls back into the server.
Storage is <ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>, YAML per token
with email/created/expires/description. Filename is the *hash* of the
plaintext, never the plaintext itself — a leak of the tokens
directory exposes hashes, not credentials. Mode 0600 / 0700, atomic
writes via temp+rename. Already shielded from public serving by the
existing dot-prefix guards in dispatch and fs.ListDirectory.
ACLMiddleware now recognises Authorization: Bearer <token>. On valid
token, sets the request email from the token file and falls through
to the existing ACL chain. On any failure (unknown / expired / store
unavailable / Bearer with no validator), returns 401 — no silent
fallback to anonymous, so a misconfigured client fails loudly.
JSON API at /.api/tokens (GET list, POST create, DELETE /<id> revoke)
backs a small inline HTML self-service page at /.tokens. Users can
only see and revoke their own tokens; cross-user revoke returns 404
to avoid leaking ownership.
--no-auth (ZDDC_NO_AUTH=1) skips ACL enforcement entirely on this
instance. On master: anyone reads everything (dev / trusted-LAN /
public-read deployments). On a downstream proxy/cache/mirror: trust
upstream's filtering, don't re-evaluate ACLs locally. Implemented as
a swap to policy.AllowAllDecider; all existing handlers keep calling
AllowFromChain unchanged. Distinct from --insecure, which only
relaxes the no-root-.zddc startup check. WARN-level startup log when
--no-auth is active so accidental enablement is visible.
33 new tests covering token storage, validation/expiry/revocation,
the JSON API end-to-end, the HTML page, and the middleware-Bearer
integration including the case-insensitive prefix and expired-token
paths. Full suite + go vet clean.
Doc updates: zddc/README.md "Authentication" rewritten to cover both
auth paths and the token UI/API; AGENTS.md gains ZDDC_NO_AUTH and a
"Bearer tokens" subsection flagging the dot-prefix-shielding pre-
condition; ARCHITECTURE.md adds "Bearer token issuance" and
"--no-auth" subsections under "Server security model" with the
hash-as-filename rationale and dispatch-shielding regression-
sensitivity called out; CLAUDE.md adds a one-line summary of the new
auth topology so future agents pick it up by default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single-click still expands the folder inline; double-click swaps the
view's root onto the folder. In server mode it loads the folder's URL
(zddc-server returns a fresh browse instance scoped there); in FS-API
mode it re-roots state.rootHandle onto the folder's handle and re-
enumerates. Files and zips are unchanged — files have a single-click
preview, zips have inline JSZip expansion.
Help panel updated to document the new behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an HTML GET hits a directory that's the rows-dir of a registered
table — i.e. parent declares `tables: { <name>: ... }` with a valid
spec, OR the default-MDL fallback applies at archive/<party>/mdl/ —
ServeDirectory now 302s to <parent>/<name>.table.html so users land
on the table view instead of a bare browse listing of the row-yaml
files. JSON GETs on the same URL fall through unchanged so the table
client can still enumerate row files.
Detection reuses RecognizeTableRequest: synthesize the equivalent
.table.html URL from the directory request and let the existing
recognizer apply its operator-vs-default-vs-missing-spec rules. No
duplicated validation.
Updates main_test.go's TestDispatchSlashRouting to expect the new
behavior on archive/<party>/mdl/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shared/build-lib.sh is sourced from each tool's build.sh with
root_dir pointing at the tool directory, so paths to other shared/
artifacts have to be \$root_dir/../shared/<file>. The slug helper
incorrectly used \$root_dir/shared/build-words.txt, which only
resolves correctly when the helper is invoked from the repo root
(e.g. via the smoke test `root_dir=\$PWD sh -c …`). In a real
./build run, the helper found the wordlist missing and silently
fell back to the short-SHA — the previous beta cut shipped
"v0.0.17-beta · 2026-05-07 · 1dafc97" instead of the intended
slug.
Fix: align the path with the existing shared/favicon.svg pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beta on-page labels now read
"v<X.Y.Z>-beta · <date> · word-word-word" instead of the previous
"… · g<short-sha>" form. Words are easier to recognise at a glance
than a 7-char SHA — testers can confirm "I'm running the
ranch-chamber-cake build" without consulting git log.
The slug is computed deterministically from the source commit's
full SHA (three 4-hex chunks modulo the wordlist length, looking
up shared/build-words.txt). Same source state → same slug, so the
existing chore-commit recursion fix carries over: a re-cut on
unchanged source produces an identical label and no spurious
embedded commit.
Wordlist (shared/build-words.txt): 283 short, neutral, English nouns
— flora, fauna, materials, landscape features. 283 ^ 3 ≈ 22M
combinations, more than enough that two simultaneous beta cuts on
different branches don't collide in practice.
Full provenance is still available — the binary's `--version` output
prints `git describe` (tag-distance-gSHA), and the chart appVersion
carries the full SHA written by notify-chart-bump.sh on push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Beta cuts now stamp the on-page label as
"v<X.Y.Z>-beta · <date> · g<short-sha>" so a tester can confirm at a
glance which source commit they're running against. Previously the
SHA was deliberately omitted to avoid an embedded-commit recursion:
a build that auto-commits the regenerated embedded artifacts
(build:971-995) advances HEAD by one, and a SHA derived from HEAD
would shift on every cut, causing the next cut to commit again
ad infinitum.
Fix: embed the *source* SHA, not HEAD. The new
_source_commit_short_sha helper walks backwards from HEAD past any
"chore(embedded): cut" or "release: vX.Y.Z lockstep" auto-commits
and returns the short SHA of the underlying source state. That SHA
is invariant across the auto-commit step, so a re-run on the same
source state produces the same label and no spurious commit.
Plain dev builds and stable cuts are unchanged. Plain builds already
included the SHA via _sha + -dirty marker; stable cuts intentionally
carry only the bare semver in the label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a virtual-URL alias so the existing form-based .zddc editor is
reachable at the natural directory location (<dir>/.zddc.html) in
addition to the legacy /.profile/zddc/edit?path=<dir> entry. Both
flow through the same renderZddcEditor body — same template, same
gate, same form-posts-to-/.profile/zddc semantics.
Wiring:
- IsZddcEditorRequest(urlPath) reports whether the URL ends with
the .zddc.html leaf (case-fold not needed; .zddc is itself case-
sensitive on disk).
- ServeZddcEditorAtPath strips the leaf, resolves the parent dir,
asserts the dir exists, gates on hasAnyAdminScope, calls the
shared renderer.
- The dispatcher routes IsZddcEditorRequest URLs BEFORE the dot-
prefix segment guard (which would otherwise 404 the .zddc.html
leaf). The route is method-gated GET-only; mutations still go
through PUT/POST/DELETE on <dir>/.zddc via the file API.
Permission model unchanged from the /.profile entry: hasAnyAdminScope
gates visibility of the editor itself; CanEditZddc decides whether
the form is interactive or read-only at the requested directory.
Subtree admins can still inspect ancestor cascade ACLs (intended
since the cascade is what determines their authority).
Test (TestDispatchZddcEditorAtPath): root admin opens project /
working/ / deployment-root editors; non-admin and anonymous both
404; missing directory 404; trailing-segment-after-leaf 404.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
URL convention for directories under a project:
- <dir>/ (with trailing slash) → browse (the directory view; same
behaviour as today)
- <dir> (without trailing slash) → the canonical default tool for
that directory's context, served
inline (no 301 hop)
Tool mapping via the new apps.DefaultAppAt(root, dir):
- working/... → mdedit
- staging/... → transmittal
- archive/ → archive
- archive/<party>/ → archive
- archive/<party>/incoming|received|issued/... → archive
- archive/<party>/mdl/... → tables (the per-party MDL grid editor)
Directories outside the canonical layout (project root, scratch
folders) keep the legacy 301-to-trailing-slash redirect since no
default tool fits.
This generalises and replaces the bespoke
"GET archive/<party>/mdl/ → 302 mdl.table.html" redirect added in PR4.
The new dispatcher rule serves the table app inline at the bare-mdl
URL by routing through RecognizeTableRequest with the canonical
.table.html suffix appended; relative fetches resolve identically
because both URLs share the same parent directory.
Tests: TestDefaultAppAt covers all canonical positions plus
case-fold and out-of-tree edges. TestDispatchSlashRouting (replacing
the now-obsolete TestDispatchMdlRedirect) verifies the slash-vs-no-
slash distinction at every canonical folder + non-canonical
fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address two follow-ups from the security review of feat/zddc-inherit-directive:
1. file.go's Inherit docstring previously claimed "the internal decider
treats it as inherit:true and emits a warning at evaluation time" —
the decider does the first part but the warning was never wired up.
Strike the over-promise; point operators at the cascade tracer
(`/.profile/effective-policy`) which surfaces both `cascade_mode`
and `chain.visible_start` so a fenced configuration that's being
ignored under strict mode is visible.
2. AllowedAtLevel hardcodes ModeDelegated. Safe today (1-level
synthetic chain, no ancestors) but a footgun if anyone migrates
the shim to a real PolicyChain later. Add a `// Deprecated:`
marker pointing at GrantedVerbsAtLevel for fence-aware paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a new "The inherit: directive" subsection in zddc/README.md
under "Access control: the .zddc cascade" describing the directive's
purpose (vendor-folder reset, regulated subtrees), the four behaviour
points (grants, roles, admins, WORM), the strict-mode refusal under
NIST AC-6, and the tracer's visibility. Cross-link from the "How a
request is evaluated" walkthrough so a reader who's looking up the
core walk can find the fence behaviour without having to scan further.
Update the "Patterns that look secure but aren't" trap #1 (same-level
allow + deny "*@company.com") to recommend inherit: false as the
preferred fix, with a worked-example .zddc snippet alongside the
broken one. The two-level gate-and-reallow pattern remains as a
fallback for federal-track deployments where inherit: is refused.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cascade tracer's JSON response now carries:
- Top-level `cascade_mode` (string): the active mode (delegated /
strict). Helps reviewers correlate the visible_start with the mode.
- Top-level `chain.visible_start` (int): chain.VisibleStart(leaf, mode)
— the lowest level whose grants the leaf can see, accounting for any
inherit:false fence in delegated mode (always 0 in strict mode).
- Per-level `inherit` (*bool, omitempty): the level's explicit inherit
value, nil when absent. A reviewer can scan the levels and see which
one fences ancestors.
The level's `exists` flag now also fires for `permissions:` and
`inherit:` entries (previously it only checked Allow/Deny/Admins),
so the response correctly reflects modern .zddc files that use the
permissions map.
Test: TestServeProfileEffectivePolicy_InheritFence builds a vendor-
folder layout, asks the tracer about a my-company user, confirms
decision=false, visible_start=1 (fence at /Vendor/), leaf.Inherit=
&false, root.Inherit=nil.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A .zddc may now declare `acl.inherit: false` to fence off ancestor
grants and roles from the descendant subtree — the "complete reset
plus add back" pattern operators want for vendor folders and other
narrowly-scoped subtrees. The cascade walker honors the deepest fence
in [0, toIdx] when evaluating any level at-or-below it, both for
GrantedVerbsAtLevel/EffectiveVerbsRange and for role lookup
(RoleMembers / lookupRoleMembers).
Federal/strict cascade mode IGNORES the fence — required by
NIST AC-6 ("ancestor deny is absolute; no leaf-level override"). So
inherit:false has no effect under strict mode and ancestor grants
remain visible. Operators running the federal Rego preset get the
same behaviour from external policy enforcement.
API surface: ACLRules.Inherit (*bool, nil = unset = inherit-true);
ACLRules.InheritsAncestors() bool; PolicyChain.VisibleStart(toIdx,
mode) int. The mode parameter is now threaded through
GrantedVerbsAtLevel, MatchesPrincipal, MatchingPrincipals,
RoleMembers, and lookupRoleMembers so role resolution is fence-aware.
Tests:
- file_test.go: parser round-trip for absent / true / false inherit
- inherit_test.go: VisibleStart (no fence, fence clamps, nested fences,
strict-mode override), EffectiveVerbs (fence hides ancestor grants,
strict-mode keeps them), RoleMembers (ancestor roles hidden by fence,
local redefinition still works)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three pieces wire the per-party Master Deliverables List as the default
view at archive/<party>/mdl/:
1. **Dispatcher redirect.** GET (and HEAD) on
<project>/archive/<party>/mdl/ (case-fold on archive and mdl) now
302 → <project>/archive/<party>/mdl.table.html. Non-archive paths
and deeper mdl/ paths fall through unchanged.
2. **Default-spec fallback in RecognizeTableRequest.** When a request
matches archive/<party>/mdl.table.html and no operator-supplied
tables: { mdl: ... } declaration covers it, the handler returns a
recognised request anyway. Operator declarations still win — and a
typo'd declaration pointing at a missing file yields 404 (not a
silent fallback).
3. **Static-file fallback for the spec yaml.** GET archive/<party>/
mdl.table.yaml and archive/<party>/mdl.form.yaml return embedded
default bytes (default-mdl.{table,form}.yaml in the handler package)
when no operator file exists at that path. Operator files always
win because the dispatcher's os.Stat finds them before reaching the
IsDefaultMdlSpec branch.
The defaults use ZDDC vocabulary: tracking, title, discipline, type,
plannedRevision, plannedDate, status (DFT/IFR/IFA/IFC/AFC/AB), owner,
notes. Operators override per-party by writing
archive/<party>/{mdl.table.yaml,mdl.form.yaml} and a tables: { mdl: ... }
entry in the party's .zddc.
Tests:
- 4 dispatcher redirect cases (success, case-fold mdl, case-fold archive,
deeper-path skip, non-archive skip)
- 6 tablehandler cases (default fires at archive/<party>/, operator
override wins, scope check, embedded yaml served, operator yaml wins,
scope check on yaml fallback)
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>
When a folder is created under <project>/staging/ whose name parses as a
ZDDC transmittal folder (YYYY-MM-DD_<tracking> (<status>) - <title>) and
whose tracking number contains -TRN- or -SUB-, also create the same-
named folder under <project>/working/ as a drafting space for staff.
The mirror is one-way and one-shot: created at staging-mkdir time only.
Renames and deletions of either side are not propagated. The
transmittal client orchestrates cleanup at issue time (move files to
archive/<recipient>/issued/, then delete both staging and working
siblings) — the server stays out of that decision.
-MDL- tracking deliberately skips the mirror; MDL deliverables live in
archive/<party>/mdl/ rows, not via the working↔staging pairing.
Implementation: mirrorStagingToWorking() in fileapi.go, called after a
successful serveFileMkdir. EnsureCanonicalAncestors handles working/'s
own auto-own .zddc; the mirror folder gets its own creator-grant on top.
Six new tests cover -TRN-/-SUB- mirror, -MDL- skip, non-transmittal
name skip, deep-path skip, and idempotency over a pre-existing sibling.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New helper pair:
- ResolveCanonicalPath(fsRoot, target) — case-fold path resolution, no side effects
- EnsureCanonicalAncestors(fsRoot, target, email…) — case-fold + MkdirAll + auto-own .zddc seeding
For each canonical position along the requested path the helpers
substitute on-disk casing (so /Project/working/foo lands in an existing
Working/ rather than a new sibling) and materialise missing
working/staging/archive/<party>/{mdl,incoming,received,issued}/ folders.
working/, staging/, and archive/<party>/incoming/ get a creator-owned
.zddc seeded automatically; received/, issued/, and mdl/ are created
without auto-own (WORM and data-store concerns respectively).
reviewing/ is rejected — purely virtual, never on disk.
Wired into the file API:
- serveFilePut — resolve before auth, ensure after auth
- serveFileMkdir — resolve before auth, ensure after auth, with
two auto-own checks (target-is-canonical OR
parent-is-canonical)
- serveFileMove (POST) — resolve src+dst, ensure dst before rename so
a move from working/<draft> →
archive/<recipient>/issued/<draft> creates
the per-party folders on the way in
7 new unit tests in zddc/internal/zddc/ensure_test.go cover lazy
creation, case-fold reuse, per-party incoming auto-own, WORM no-auto-own,
empty-principal skip, reviewing rejection, and traversal rejection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The transmittal-folder grammar was duplicated as a private regex inside
the archive package. Replace the local regex with calls to the shared
parser in zddc/internal/zddc/folder.go so the grammar lives in one
place and the upcoming staging→working mirror logic can reuse it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BREAKING CHANGE. Project-level Issued/Received/Incoming folders no
longer carry special semantics. WORM enforcement and auto-ownership
move to the per-party canonical layout:
- WORM mask now triggers on archive/<party>/received/ and
archive/<party>/issued/ (any case, any party)
- Auto-own .zddc writes on first mkdir under working/, staging/,
or archive/<party>/incoming/ (any case)
Predicate API:
- IsAutoOwnPath(parentDir, fsRoot) — replaces IsAutoOwnParent(name)
- IsWormPath(requestPath) — same name, new pattern
- WormFolderLevelIndex unchanged signature, new pattern
Legacy SpecialFolderNames / AutoOwnFolderNames / WormFolderNames /
IsAutoOwnParent are deleted (no Deprecated: stubs — early-development
project, no back-compat to preserve).
Tool availability (apps/availability.go) is case-fold throughout:
- mdedit: descendants of working/
- transmittal: descendants of staging/
- classifier: descendants of working/, staging/, or
archive/<party>/incoming/
Working/, WORKING/, working/ all match identically.
Test fixtures rewritten:
- special_test.go: covers IsAutoOwnPath / IsWormPath /
WormFolderLevelIndex / ResolveCanonical / canonical lists
- availability_test.go: per-party rules, case-fold scenarios
- fileapi_test.go: rolePermissionsTestSetup now seeds
Project-X/archive/Acme/{incoming,issued,received}/ rather than
Vendor/{Incoming,Issued,Received}/ at the project root
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure refactor. The mkdir post-hook in handler/fileapi.go duplicated
zddc-package types; lifting the body into the package itself lets the
upcoming EnsureCanonicalAncestors helper share it without re-exposing
the file API's internals.
No behaviour change. The grant shape (creator email → rwcda + CreatedBy
audit field) and the atomic-write path through zddc.WriteFile are
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce the lowercase canonical folder model that the new auto-create
feature will key off:
- ProjectRootFolders = [archive, working, staging, reviewing]
- PartyFolders = [mdl, incoming, received, issued]
- AutoOwnCanonicalNames = [working, staging, incoming]
- VirtualOnlyCanonicalNames = [reviewing]
ResolveCanonical(parentDir, logical) does a case-fold lookup against
os.ReadDir(parentDir) so a manually-created Working/ is reused rather
than shadowed by a new working/ sibling.
Pure addition. The existing SpecialFolderNames / AutoOwnFolderNames /
WormFolderNames are kept (now Deprecated:) so dependent packages keep
compiling until the predicate rewrite lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts the YYYY-MM-DD_<tracking> (<status>) - <title> grammar into a
reusable parser in the zddc package, and exposes a tracking-type
predicate for -TRN- / -SUB- (case-fold). The transmittal-folder regex
was previously only inside archive/index.go where it captured just the
date; the new ParseTransmittalFolder also returns tracking, status, and
title so handlers can recognise transmittal envelopes for upcoming
staging↔working mirror logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The default "no commit without ask" guidance is too restrictive for this
repo's small-commit workflow. Spell out the policy: commits are made as
appropriate to the work, each push is a fresh approval, and granular
history is preferred over squash-on-push.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .archive virtual prefix is now project-scoped at exactly one URL
depth: any /<project>/<sub>/.../.archive/... gets a 301 to the
canonical /<project>/.archive/.... The dispatcher does this before
calling the handler; query strings are preserved (the browser handles
the fragment automatically). .archive is also GET/HEAD-only — anything
else returns 405 with Allow: GET, HEAD, ahead of the file API.
Why: offline-built HTML files reference siblings as
"../.archive/<tracking>.html" from arbitrary depths. All of those refs
should converge on a single stable URL per (project, tracking) so
external links and bookmarks don't fork by entry point.
Permissions now follow the resolved file, not .archive itself.
.archive is a virtual surface — it has no on-disk directory and no
.zddc of its own, so gating it as if it did is wrong. Two gates only:
- Resolve: only the per-target file's ACL chain decides. A user
explicitly allowed at one transmittal folder but denied at the
project root can still fetch tracking numbers that resolve there.
Per-target denial returns 404 (not 403) so existence doesn't leak.
- Listing: filter entries by per-target ACL. If the project bucket
has zero indexed entries → 404 (unknown / empty project, indistinguishable
from a probe). If the bucket is non-empty but the caller can read
no entries → 403 (existence-leak guard: don't confirm an inaccessible
project's archive exists). Otherwise → 200 with the filtered subset.
The listing endpoint is now content-negotiated like ServeDirectory:
Accept: text/html serves the embedded `browse` SPA bytes (with the
embedded ETag and X-ZDDC-Source: embedded:browse); Accept:
application/json returns the JSON entry array (with content-hash ETag
and 304 short-circuit). Vary: Accept set on both. The browse SPA's
auto-detect path-fetch then renders the archive entries as a sortable,
filterable flat list at /<project>/.archive/.
ServeArchive's signature is now (cfg, idx, w, r, project, filename) —
the dispatcher hands the normalized project string in directly, so
projectFromContextPath is gone. Old behavior was to derive project
from contextPath inside the handler; with the upstream redirect that's
redundant and the handler's preconditions are simpler.
Tests: archivehandler_test.go rewritten around the new semantics;
added per-target-only resolve, project-root-deny + per-target-allow
rescue, listing 403/404 distinction, JSON/HTML content-negotiation,
and conditional GET. main_test.go gains TestDispatchArchiveRedirect
(deep paths, query preservation, already-canonical no-op) and
TestDispatchArchiveMethodGate (PUT/POST/DELETE → 405).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fsnotify watcher only sees events the local kernel generates, so on
SMB/CIFS-backed roots (Azure Files) writes from any other client are
invisible — the archive index would silently miss them until pod
restart. Add two backstops:
1. Periodic full re-walk via Index.Rebuild on a configurable interval
(--archive-rescan-interval / ZDDC_ARCHIVE_RESCAN_INTERVAL, default
60s, 0 to disable). Atomically swaps ByProject under the existing
RWMutex; concurrent reads stay safe.
2. Admin-only POST /.profile/reindex that triggers an immediate rebuild
and returns {duration_ms, project_count, tracking_count}, for the
"I just dropped 50 files and don't want to wait" case. Gated by
IsAdmin with the same 404-on-non-admin pattern as the other admin
sub-resources.
Tests: TestRebuild_PicksUpAddsAndDrops covers add+drop semantics and
returned counts; TestServeProfileReindexPOST covers the happy admin
path; matrix entries cover the gate (anonymous/non-admin → 404, admin
GET → 405 method-not-allowed since the route is POST-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the long-standing chart-bump race that required a manual rebase
on every beta cut. Three coordinated changes:
build (top-level): broaden the existing stable-only "fold embedded
artifacts into a release commit" block to also fire on beta cuts.
Same idempotency check; new commit message ("chore(embedded): cut
v<X.Y.Z>-beta") derived via _coordinated_next_stable. Tagging stays
stable-only (channels are mutable mirrors and never get tags). Beta
cuts now produce exactly one commit on main; HEAD always carries
the bytes the binary will serve.
shared/build-lib.sh: drop the SHA from alpha/beta channel labels.
Embedding HEAD's SHA in the bytes the SHA identifies created a
feedback loop — each auto-commit advanced HEAD, which shifted the
SHA in the next run's versions.txt, which triggered another
embedded commit, ad infinitum. Channel labels now read
"v<X.Y.Z>-<channel> · <date>" — version + date is enough; SHA
traceability lives in the chart's appVersion (full SHA) and the
binary's --version output. Plain dev builds keep the timestamp +
-dirty fingerprint since they don't commit. Stable cuts already
use a clean version-only label.
.forgejo/scripts/notify-chart-bump.sh: pin the chart's appVersion
to `git rev-parse HEAD` instead of the SHA in versions.txt. The
build's auto-commit now ensures HEAD == "the commit containing the
embedded bytes the binary will bake," so HEAD is the substantively
correct anchor. The previous versions.txt read pinned one commit
too early (the source-side commit, before the embed refresh
committed) — every beta cut required a manual chart-rebase to
point at the embed commit. With both halves landed, the cycle is
zero-touch: ./build beta + git push → auto-bump CI fires → chart
appVersion at correct SHA → dev image bakes the right bytes.
Verification: ran ./build beta twice on the same source state. First
run produced one commit; second run printed "no embedded changes to
commit (re-run on same source state)" and made no commit. The label
SHA-loop bug is fixed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refreshes //go:embed bytes off fe28a73. Dev image now ships the new
tables renderer (handler-local embed at handler/tables.html) plus
build-label refresh on the six cascade-served tools.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolved `.archive/<tracking>.html` URLs now serve the target file's
bytes inline via http.ServeFile with Cache-Control: no-cache, replacing
the previous 302 redirect to the per-transmittal URL.
Why: external links like `.archive/<tracking>.html#section` are meant
to track the latest revision. A redirect exposes the snapshot URL — any
forwarded link then pins to that snapshot instead of "latest." Serving
in-place keeps the `.archive/` URL stable as the resolver's "current"
target moves over time.
Cache-Control: no-cache is intentional. Each load revalidates against
the on-disk file's Last-Modified/ETag, so when a new revision lands the
resolver picks it and the browser refetches transparently.
ACL is unchanged: enforced on both the `.archive` context directory and
the resolved target file (per-target denial returns 404, not 403, to
avoid disclosing that a tracking number exists in a hidden subtree).
archivehandler_test.go status expectations updated 302 → 200; fixture
bodies adjusted for body-content verification of the in-place serve.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tables is the eighth HTML tool: a read-only tabular view over a
directory of YAML files declared via `tables:` in `.zddc`. Anchor use
case is the Master Deliverables List, where each row is one
`<tracking>.yaml` under `Archive/<Party>/MDL/`. Rows click through to
the existing form renderer for editing.
Schema (zddc/internal/zddc/file.go)
- New `Tables map[string]string` on ZddcFile. Map key becomes the URL
stem (`tables[MDL]` → `<dir>/MDL.table.html`); the value is a path
relative to the .zddc pointing at a `*.table.yaml` spec describing
columns + the rows directory. No upward cascade in v1 — each
directory hosting a table declares it directly.
Server handler (zddc/internal/handler/tablehandler.go)
- `RecognizeTableRequest` matches GET `/<dir>/<name>.table.html`
against the cascade's `tables:` declarations. Dispatch routes
table requests before the form-system intercept.
- `ServeTable` ACL-gates with `policy.ActionRead` and serves the
embedded `tables.html` template; client walks the directory itself
via the listing JSON or FS Access API.
- tables.html embedded via //go:embed — same pattern as form.html.
Frontend (tables/)
- Vanilla JS: app/context/util/filters/sort/render/main modules.
- Reads spec + row YAML files via window.zddc.source (HTTP polyfill
or local FS handle); js-yaml 4.1.0 vendored in shared/vendor for
client-side parsing.
- Sample fixtures under tables/sample/ for local testing.
Build + CI
- Lockstep build registers tables alongside the other 7 tools (HTML
output, embed mirror, versions.txt, release-output, tags).
- Playwright project added; `npx playwright test --project=tables`
is part of `npm test`.
Drive-by: rename mdedit Playwright selectors `#select-directory` →
`#addDirectoryBtn` to fix three pre-existing failing tests.
Drive-by: ignore locally-built `zddc/zddc-server` binary so it doesn't
get accidentally staged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refreshes the //go:embed bytes off 3115e38. Dev image (ZDDC_REF=main)
now ships the file API and verb-based RBAC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the binary acl.allow/deny model with five permission verbs
(r/w/c/d/a) and first-class roles, and adds an authenticated file API
(PUT/DELETE/POST move/mkdir) so the HTML tools can edit-in-place over
HTTP. Closes the AC-3(7) and AC-6 federal-readiness gaps.
File API (zddc/internal/handler/fileapi.go)
- PUT <new> → action c
- PUT <existing> → action w
- PUT <.zddc> → action a (CanEditZddc strict-ancestor rule)
- DELETE → action d
- POST mkdir → action c (auto-writes creator-owned .zddc when the
parent is Incoming/Working/Staging)
- POST move → action w on src + c on dst, atomic via os.Rename
- Optional If-Match for optimistic concurrency, --max-write-bytes cap,
audit log emits a structured file_write event per operation.
Permission model (zddc/internal/zddc/{acl,file,roles,cascade_mode}.go)
- acl.permissions: { principal → verb-set } map; principals are email
patterns or role names. Empty verb set is an explicit deny.
- roles: { name → members } definitions, available at the level they
declare and all descendants. Closer-to-leaf shadows ancestor.
- Legacy acl.allow/deny still work; they fold into permissions at
parse time (allow → "rwcd", deny → "").
- Cascade walks leaf→root; first level with any matching entry wins;
the union of matching verb sets at that level decides.
- --cascade-mode=strict adds a root→leaf ancestor-deny pre-pass so an
ancestor explicit-deny is absolute (NIST AC-6). Default delegated
preserves the existing commercial behavior.
Special folders (zddc/internal/zddc/special.go)
- Incoming / Working / Staging: mkdir auto-writes a .zddc into the new
subdir granting created_by + that email rwcda directly. Same form
operators write by hand; creator can edit it later to add others.
- Issued / Received: server-enforced WORM split. Cascade grants
inherited from above the WORM folder are masked to r only; grants
placed at-or-below the WORM folder retain r,c. Operators grant
write-once (cr) to the doc controller via an explicit .zddc at the
Issued/Received folder. Admins exempt — only escape hatch.
Browser polyfill (shared/zddc-source.js)
- HttpDirectoryHandle + HttpFileHandle implement the FS Access API
surface (values, getFileHandle, createWritable, removeEntry,
queryPermission/requestPermission) over zddc-server's listing JSON
and file API. Existing tools written against showDirectoryPicker
work unchanged.
- detectServerRoot() returns { handle, status }: tools auto-load on
HTTP, surface a clear "no permission to list" message on 403, and
fall back to the welcome screen on 0.
- classifier renames take the atomic POST move path on HTTP-backed
handles; mdedit and transmittal route reads/writes through the
polyfill so prior FS-API code paths cover both modes.
Tests
- zddc/internal/zddc/{cascade_mode,roles,special,acl}_test.go cover
delegated vs strict, role membership / shadowing / legacy fallback,
WORM split semantics, verb-set parser round-trip.
- zddc/internal/handler/fileapi_test.go now also covers role-based
vendor scenarios, WORM blocking vendor & doc controller writes,
explicit Issued .zddc unlocking the cr drop-box, admin bypass,
auto-ownership on mkdir, and strict-mode lockouts.
Docs
- ARCHITECTURE.md + zddc/README.md document the verb model, role
syntax, special-folder behaviors, cascade-mode flag, and full file
API surface. Federal-readiness gap analysis strikes AC-3(7) and
AC-6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dev chart's overlay-isolation layer (added in 9765fa2) was not
called out in helm/README.md or zddc-server-dev/Chart.yaml. Readers
comparing the two charts saw "same shape but tracks main" without
learning that the dev chart wraps the data PVC in OverlayFS so its
writes never mutate the underlying store.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Verify your downloads" section now ends with two side-by-side
"Configure your server" cards demonstrating both ways to set the
trusted public key:
- Env-var path: curl pubkey.pem to disk, point ZDDC_APPS_PUBKEY at it
- Inline PEM: paste under apps_pubkey: in root .zddc
The cards include the actual PEM bytes of the canonical-channel key
(matching the file at /pubkey.pem) so an operator who picks the
inline form can copy-paste directly. Each card explains when it fits:
env-var for k8s/systemd/Docker plumbing, inline for the
"all-config-in-one-file" mental model.
Replaces the previous trailing prose paragraph, which mentioned both
options but didn't show either concretely. Real example beats prose
explanation when the goal is "get the operator to a working
configuration on first read."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a second way to configure the apps signing pubkey alongside the
existing --apps-pubkey / ZDDC_APPS_PUBKEY (path-to-PEM-file) form: an
inline PEM block under apps_pubkey: in the root .zddc file. Resolution
order:
1. --apps-pubkey / ZDDC_APPS_PUBKEY (path) ← env/flag wins
2. apps_pubkey: inline PEM in root .zddc ← second
3. nothing ← URL fetches refused
Honored only at the root .zddc — same trust-anchor treatment as the
existing admins: field. Subtree write authority cannot re-anchor
trust because subtree apps_pubkey: entries are ignored. (Same
unmarshal pattern as the rest of ZddcFile; the root-only enforcement
is in setupApps where we explicitly read filepath.Join(cfg.Root,
".zddc") rather than walking a chain.)
Why offer both: env/flag fits k8s + systemd deployment shapes where
the operator already manages a config volume and prefers env-based
plumbing. Inline-in-.zddc fits the "everything in one config file"
mental model and matches how operators already think about admins:
and acl:. Either ships a working URL-fetch-verify story; the choice
is operator preference.
Logged differently per source so operators can grep for which path
populated the key:
apps signing pubkey loaded source=env/flag path=/path/to/pubkey.pem
apps signing pubkey loaded source="root .zddc apps_pubkey"
Smoke-tested end-to-end: a root .zddc with inline apps_pubkey: PEM
block + apps: archive: <upstream-URL> + ZDDC_APPS_PUBKEY unset —
the server logs "loaded source=root .zddc apps_pubkey" at startup,
fetches the URL, verifies the .sig against the inline key, caches.
Tampering still rejects; missing .sig still rejects; everything that
worked yesterday still works.
Docs: env-var tables in zddc/README.md and AGENTS.md note the
inline alternative; the federal-readiness gap analysis subsection
on code signing now lists both paths in its resolution order; the
release-page "Verify your downloads" section mentions both for
operators.
Production binary unchanged at ~13 MB. All 11 Go test packages green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two interlocking pieces shipped together:
1. Strict Ed25519 signature verification on URL-fetched apps artifacts.
Every URL the apps cascade resolves must publish a corresponding
<url>.sig (raw 64-byte Ed25519 signature). The fetcher rejects on
any failure (sig 404, transport error, wrong key, tampered body)
and the resolver falls back to the embedded copy.
The trusted public key is OPERATOR-CONFIGURED via --apps-pubkey /
ZDDC_APPS_PUBKEY (PEM file path). No baked-in default — same posture
as TLS certificates. Operators using zddc.varasys.io's canonical
channels download pubkey.pem from there and configure the local
path. Operators with their own signing infrastructure pass their
own public key.
Build pipeline (./build) gains sign_release_artifacts: walks
dist/release-output/ after promote and produces an Ed25519 .sig
alongside every real file. ZDDC_SIGNING_KEY=~/.config/zddc-signing/
key.pem (mode 0600). Symlinks skip — the .sig at the symlink
target is what counts.
Test coverage: parse-PEM round-trip, malformed/wrong-type PEM
rejection, valid-signature accept, tampered-body reject, wrong-key
reject, malformed-signature reject, end-to-end fetch+sign+verify,
fetch-rejects-tampered, fetch-rejects-missing-sig, fetch-rejects-
wrong-key. Existing fetch tests updated to use signed-fixture
helpers.
2. Dev Helm chart mounts production data READ-ONLY and layers an
OverlayFS writable scratch on top. Prod data is the lowerdir;
dev's writes (form submissions, archive index state, .zddc edits)
land in upperdir; main container sees the merged read-write view
at $ZDDC_ROOT. Setup runs in a privileged init container; main
container runs unprivileged. Solves the dev-replica-on-shared-
dataset problem at the filesystem layer with no zddc-server code
change.
Docs: env-var tables in zddc/README.md and AGENTS.md gain a
ZDDC_APPS_PUBKEY row. The Federal-readiness gap analysis "Code-signed
apps: URL fetches" subsection is rewritten as "what's currently in
place" instead of "what would need to be added," with a forward
pointer to per-entry signed_by: (multi-key) and Sigstore as the
federally-acceptable evolution.
The website "Verify your downloads" section + the embedded pubkey
gone — but the website needs separate updates landing in zddc-website
to publish pubkey.pem and add the verify section. Pending in that
repo's commit.
Production binary unchanged at 13.1 MB. All 11 Go test packages green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two doc/website improvements:
build:341 build_releases_index() — new "Build your apps: block" section
between the pinning narrative and the channels explainer. Per-app
dropdowns (one each for archive/transmittal/classifier/mdedit/landing),
a live-updating YAML textarea, and a Copy button. The dropdowns clone
their options from the existing #version-picker (channels at top,
pinned versions below) so we don't duplicate version data into JS —
the picker is the single source of truth for "what versions exist."
~80 lines of HTML+JS added; no SHA-256 anywhere (per user direction
that code signing is the future supply-chain answer, not hash pinning).
zddc/README.md § Federal-readiness gap analysis — promoted four items
that previously were one-line bullets to per-item subsections so a
future implementor doesn't have to redo the design conversation:
- FIPS-validated cryptography (NIST SC-13): captures cgo + OpenSSL
implications, the platform-matrix reality, and the parallel
zddc-server-fips build target architecture (linux-amd64 only,
RHEL/UBI base, validated OpenSSL on host).
- Authenticated proxy↔server channel (NIST IA-3): mTLS vs JWT
trade-offs spelled out. Recommended: JWT first; mTLS available
for deployments that already operate a private CA.
- Policy export for change control (NIST CM-3): zddc-server policy
export subcommand emitting every directory's resolved ACL in
JSON / Markdown / CSV. Reuses zddc.ScanZddcFiles +
zddc.EffectivePolicy + zddc.MatchesPattern.
- Code-signed apps: URL fetches (NIST SI-7): replaces SHA-256
pinning (operator hash-tracking burden) with code signing
(operator trusts a public key once). Three-part implementation
(build pipeline signs, public key on website, verifier in
apps/fetch.go).
The bullet list at the top of the gap analysis stays as a one-line
index pointing at the subsections.
Items #6 (ABAC roles) and #7 (logs: block in root .zddc) stay as
bullets — commercial-deployment features, not federal-track.
No code changes to the binary. No tests touched. ~280 lines added
across the two files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The button was renamed from 'Select Directory' to 'Add Local Directory'
in 22c142e (chore(headers): standardize across all 7 tools). The
template, in-tool welcome hint, and in-tool help text all updated;
mdedit/README.md was missed.
Investigated the underlying bug report ('Select Directory button isn't
showing'): the button itself is fine — present in template.html with
btn-primary class, never hidden, only visually demoted to btn--subtle
once a directory is loaded. The user's report was new-label confusion
from the rename. Fixing the README to match the rendered label closes
the discrepancy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ship a second parity-tested Rego policy that flips the cascade's
leaf-allow-overrides-parent-deny rule for NIST AC-6 conformance.
Standard cascade (existing access.rego, mirrors internal Go evaluator):
Bottom-up walk; first explicit match wins; deny-first within a level.
A leaf-level allow CAN override an ancestor's deny. This is the
cascade's intentional delegation property — a project-owner who
re-allows a previously-denied collaborator works as expected.
Federal mode (new access_federal.rego):
Any deny anywhere along the chain is absolute. An allow only matters
if no level (any depth) has denied the same email. Required by
NIST AC-6 default expectations: a central admin's deny at the root
must be unbypassable by a tenant who controls a subtree's .zddc.
Operators run real OPA with this Rego and point ZDDC_OPA_URL at it;
the internal Go evaluator stays on the commercial cascade. The
toggle is "which policy does your OPA evaluate," not a knob inside
zddc-server.
Surfaced via --print-rego flag:
zddc-server --print-rego # standard (default)
zddc-server --print-rego=standard # same
zddc-server --print-rego=federal # AC-6 strict variant
Parity test (federal_parity_test.go) compiles both Regos and asserts:
* They AGREE on every cascade scenario where no ancestor-deny
intersects a leaf-allow (most cases).
* They DISAGREE — by design — on the three scenarios where the
AC-6 rule differs:
- "leaf allows what parent denied" → standard allows, federal denies
- "deep leaf re-allows after middle deny" → same
- "glob deny at root + specific allow at leaf" → same
Cross-checks the divergence flag explicitly so any future change that
accidentally collapses the two policies fails the test.
Closes the AC-6 row of the federal-readiness gap analysis (now marked
"partially complete" in zddc/README.md — the full bullet would be a
built-in --policy-mode=federal toggle that also flips the in-process
Go evaluator).
Production binary unchanged at 13.1 MB (Rego files embedded as bytes;
OPA library remains test-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eliminates the manual cascade-trace ritual when debugging "why can't
alice see /Project-X" reports. New endpoint returns the resolved
policy chain plus the active decider's verdict in JSON:
GET /.profile/effective-policy?path=/Project-X/sub/&email=alice@…
Response shape:
{
"path": "/Project-X/sub/",
"email": "alice@…",
"decision": true,
"decider_kind": "*policy.InternalDecider",
"chain": {
"has_any_file": true,
"levels": [
{"index": 0, "zddc_path": "/.zddc", "exists": true,
"acl": {...}, "admins": [...],
"matches_email": false, "decision_at_level": "no_match"},
{"index": 1, "zddc_path": "/Project-X/.zddc", "exists": true,
"acl": {...}, "matches_email": true, "decision_at_level": "allow"}
]
}
}
Per-level email matching reuses the same MatchesPattern code the live
evaluator uses, so the trace can never disagree with the actual
verdict — and when ZDDC_OPA_URL points at an external OPA, the
decision goes through that OPA, making the endpoint a useful smoke
test for OPA wiring too.
Admin-only via the existing /.profile gate (404 to non-admins).
Required params; 400 if either is missing or path doesn't escape ROOT.
Test coverage:
* TestServeProfileGateMatrix: anonymous → 404, non-admin → 404,
admin without params → 400 (gate cleared, validator rejected)
* TestServeProfileEffectivePolicy: full payload-shape assertion
against a worked-example fixture (closed project where alice is
allow-listed but bob is not)
Also fixes pre-existing doc drift: README's "Admin Debug Page"
section referenced /.admin/whoami|config|logs but the actual code
mounts /.profile/* (the rename predates this PR; the doc was stale).
Closes the "/.admin/effective-policy debug endpoint" item from the
federal-readiness future-work list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>