Two layers shipped together since the second builds on the first.
LAYER 1 — reviewing/ + Plan Review scaffolding
- reviewing/ is now a real folder under each project, populated by the
Plan Review composite endpoint. The old reviewing/ virtual aggregator
handler is retired.
- POST /<project>/archive/<party>/received/<tracking>/ with X-ZDDC-Op:
plan-review scaffolds physical workflow folders under reviewing_root
and staging_root, each carrying .zddc.received_path pointing back at
the canonical submittal. Idempotent re-runs match by received_path
and re-converge the ACL.
- Virtual received window: when listing or writing under
<workflow>/received/, the server resolves through the canonical
archive/<party>/received/<tracking>/ via the workflow's
.zddc.received_path. Writes get rewritten to
<workflow>/<base>+C<n><suffix> so review comments land in the
workflow folder and never touch the WORM archive.
- Cascade defaults declare on_plan_review per project so the
reviewing_root and staging_root are configurable.
LAYER 2 — browse context-menu workflows
- Accept Transmittal: right-click a transmittal folder in
archive/<party>/incoming/ → validates ZDDC folder + filename
conformance, atomic-renames the folder to
archive/<party>/received/<tracking>/ (WORM zone), and optionally
chains into Plan Review in the same composite request. Re-acceptance
with a different revision merges file-by-file; WORM forbids
overwrite of an existing filename.
- Stage / Unstage: right-click files in working/<…>/ → "Stage to…"
with picker of existing staging transmittal folders + inline
"New transmittal folder…" create; right-click files in
staging/<…>/ → "Unstage to working/" defaulting to the user's
working/<email>/ home. Reuses the file-API move primitive.
- Create Transmittal folder: right-click the staging/ pane → prompts
for a ZDDC-conforming folder name with live validation; mkdir,
then navigate to the new folder URL where the transmittal tool
serves the editor.
- Supporting infrastructure: new CanonicalFolderAt cascade lookup +
X-ZDDC-Canonical-Folder response header so the browse SPA can
scope-gate menu items without re-implementing the cascade
client-side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New endpoint GET /<path>/foo.md?convert=docx|html|pdf renders a markdown
source on demand. Surfaced as the Download buttons in browse's markdown
editor (separate commit).
Execution model — two upstream container images, lazy-pulled:
• docker.io/pandoc/latex:latest — MD→DOCX, MD→HTML (entrypoint pandoc)
• docker.io/zenika/alpine-chrome — HTML→PDF (entrypoint chromium-browser)
No custom image build. The runner passes --pull=missing on every podman/
docker invocation so the operator only needs the runtime installed —
first request pulls the image, subsequent requests use the local cache.
Overrides: --convert-pandoc-image / --convert-chromium-image (and the
matching ZDDC_CONVERT_* env vars). Engine: --convert-engine (podman
preferred, docker fallback). Resource caps: --convert-mem-mib (512),
--convert-cpus (2), --convert-pids (100), --convert-timeout (30s).
PDF flow is two-stage: pandoc renders the markdown through the embedded
viewer-template.html to standalone HTML, then chromium prints that HTML
via --print-to-pdf. Preserves the print-media CSS already authored in
viewer-template.html rather than going through pandoc's LaTeX template.
Each conversion runs in a throw-away container with --rm --network=none
--read-only --tmpfs=/tmp --cap-drop=ALL --security-opt=no-new-privileges
--env=HOME=/tmp plus a bind-mounted scratch dir for I/O. Pandoc reads
markdown from stdin / writes to stdout; the viewer template lives at
/tpl (ro). Chromium reads HTML from a read-write bind mount at /pdf
and writes the PDF to the same mount; the host reads it back. No shell
wrappers, no shell quoting — argv flows straight into each image's
entrypoint.
On-disk cache at <dir>/.converted/<base>.<ext> with mtime synced to the
source. Fast path is a stat-and-serve with no exec; slow path
singleflights concurrent requests for the same target. PUT/DELETE/MOVE
on the source .md purges the .converted/ sidecars.
Per-project template variables (client/project/contractor/project_number)
come from a new .zddc `convert:` cascade block, walked leaf→root with
per-key latest-wins. Filename-derived variables (title, tracking_number,
revision, status, is_draft) come from a new zddc.ParseFilename helper.
If neither podman nor docker is on PATH, the endpoint serves 503 with
a clear Retry-After. The rest of the server keeps working.
This is the first os/exec site in the codebase. The hardening in
internal/convert/runner.go — context.CancelFunc → process kill,
cmd.WaitDelay, platform-specific SysProcAttr (Setpgid + Pdeathsig on
Linux), minimal env, stdout cap via limitWriter, stderr ring buffer —
sets the pattern for any future shell-outs.
Public surface:
convert.ToDocx(ctx, source, meta) / .ToHTML / .ToPDF
convert.Probe(ctx, engineOverride) → install Runner if engine present
convert.SetImages(pandoc, chromium)
convert.ConfigureLimits(memMiB, cpus, pids, timeout)
convert.Available()
Container handler at internal/handler/converthandler.go; dispatcher
branch in cmd/zddc-server/main.go inserts the convert lookup after the
existing ACL gate, reusing the source file's read policy verbatim.
The trailing-slash directory form was hardcoded to serve `browse`. Add a
`dir_tool` .zddc key (cascades leaf→root, floors at `browse`) so an
operator can point a subtree's slash form at another directory-oriented
tool — the symmetric counterpart to `default_tool` (the no-slash
"specialized app"). handler.ServeDirectory now resolves it via
zddc.DirToolAt; JSON listing requests are unaffected (raw listing
always served, so browse can still enumerate).
Also collapse the no-slash dispatch: the on-disk-directory and the
virtual-declared-path branches in main.go each carried their own copy
of "default_tool → tables-carveout-or-apps.Serve → 302", with
inconsistent ACL checks. Extract one chokepoint, serveSpecializedNoSlash,
that enforces ACL uniformly for every default_tool route.
Updates ARCHITECTURE.md and AGENTS.md: the stale "Special folders" /
hardcoded-availability sections now describe the .zddc-cascade model
(defaults.zddc.yaml, the schema-key table, the slash/no-slash
convention, WORM, standard roles).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Answers "can roles reset as well as add?" — yes, both now.
Role membership UNIONS across the cascade:
- A deeper .zddc that defines an inherited role again with one
extra member ADDS that member (was: deepest definition shadowed
the ancestor's entirely).
- New `reset: true` on a role definition breaks the union — that
level's members are authoritative, ancestor definitions above
are excluded; descendants below still union on top. Use it to
give a project its own team independent of a deployment-wide
default.
- lookupRoleMembers / RoleMembers reworked: walk deep→shallow,
union members, stop at the first reset:true; finally fold in
chain.Embedded.Roles as the baseline so a role declared only in
defaults.zddc.yaml is "defined" (and a deployment's on-disk
redefinition unions on top).
Admin checks are now role-aware:
- IsSubtreeAdmin / CanEditZddc's strict-ancestor scan use
MatchesPrincipal instead of MatchesPattern, so `admins:
[document_controller]` resolves to the role's members. The
strict-ancestor scan resolves roles only up to level i, so a
role defined at the deepest level (= dirPath) never confers
self-edit rights.
Two standard roles ship in defaults.zddc.yaml (empty members — a
fresh deployment grants nothing until they're populated):
document_controller — files into the WORM zones. Gets:
- rw at the project level (read + overwrite-existing; NOT c, so
it can't make arbitrary folders)
- rwc at archive/ (can create party subfolders)
- subtree-admin at working/ and staging/ (full create + manage,
including taking over a fenced per-user home) — scoped HERE,
not at the project root, so the WORM constraint still binds
it in archive/<party>/received|issued
- listed in worm: on received/ and issued/ → write-once-create
survives the WORM mask
project_team — read-only across the project. The per-user
working home's fenced auto-own .zddc (rwcda for the creator)
wins via deepest-match, so "read-only except what I own" falls
out of the cascade with no special rule. Inside received/issued
their r is preserved (worm: doesn't strip read).
archive/<party>/ gains `auto_own: true` (UNFENCED) so whoever
creates a party subtree (normally the doc controller) owns it and
can set up that counterparty's .zddc afterward — without fencing,
project_team:r still cascades through to received/issued.
Tests: roles_test (union + reset), standardroles_test (the
doc-controller scoped-create matrix + project-team read-only-except-
owned), ensure_test updated for the new party-folder auto-own.
fileapi_test's WORM doc-controller test already uses worm: [role].
All Go + 248 Playwright tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per design feedback: the verb string in a worm: entry was always
effectively "cr" (the key's whole job is to restore write-once-create
inside the locked zone, and you need read to see what you filed), so
spelling it out per-entry was redundant. worm: is now just a list of
principal patterns — email-globs, @role:name, or bare role names —
and every listed principal gets read + write-once-create. An empty
list ([]) still marks the WORM zone with no create-capable
principals.
Changes:
- ZddcFile.Worm: map[string]string → []string
- mergeOverlay: concat-dedupe (a deeper .zddc adds controllers);
mergeStringSlicePreserveEmpty keeps `worm: []` non-nil through
the overlay so it still marks the zone
- WormZoneGrant: walks the list, grants VerbsRC to each matching
principal; result is always ⊆ {r, c}
- ValidateFile: validates each entry as an email-glob (role refs
skipped — validated by the role machinery)
- defaults.zddc.yaml: received/ and issued/ carry `worm: []`
- tests updated to the list form (worm_test.go, fileapi_test.go)
All Go tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WORM (write-once-read-many) is no longer a special folder type keyed
off the literal names "received"/"issued". It's a cascade key —
`worm:` on any directory's .zddc — with the ACL-shaped semantics the
user described.
Schema:
worm:
"doc-control@example.com": cr # email-glob or @role:name → verbs ⊆ {r, c}
# an empty map ({}) is a WORM zone with no create-capable principals
Effect inside a WORM zone (any cascade level declares worm:), applied
AFTER the normal cascade ACL and BEFORE the admin escape hatch:
- w / d / a stripped for everyone
- c survives only via the worm: map
- r survives via the normal ACL OR the worm: map (so a document
controller who isn't in the project ACL still gets read+create)
- worm: grants UNION across the cascade — deeper .zddc can name
more controllers
- admins (root / subtree) bypass entirely — handler does the
IsAdmin check before the policy evaluator
defaults.zddc.yaml: archive/<party>/received and archive/<party>/issued
carry `worm: {}` (WORM zone, no controllers — the deployment names
its document controller by adding a deeper .zddc with
`worm: {<principal>: cr}`). The canonical convention is unchanged;
the difference is an operator can now mark any directory WORM, or
rename received/issued, without a code change.
Removed (hardcoded path predicates, superseded by the cascade walk):
zddc.IsWormPath
zddc.WormFolderLevelIndex
zddc.splitPathSegments (only IsWormPath used it)
Kept: zddc.WormMask (generic verb-set primitive), zddc.VerbsRC.
New:
zddc.WormZoneGrant(chain, email, mode) → (verbs, inWormZone)
Walks the chain for worm: declarations; unions the principal's
grants masked to {r, c}.
policy.InternalDecider.Allow: WORM block rewritten to consult
WormZoneGrant instead of IsWormPath/WormFolderLevelIndex.
ValidateFile: worm: keys validated as email-glob (or @role:name);
values validated as verb strings ⊆ {r, c}.
Tests:
- new worm_test.go covers the embedded convention, operator-granted
controller, w/d masking, cross-cascade union.
- special_test.go's TestIsWormPath / TestWormFolderLevelIndex
retired; TestWormMaskStripsWDA kept.
- fileapi_test.go's WORM tests updated: the doc-controller grant is
now `worm: { _doc_controller: cr }` at issued/.zddc, not
`acl.permissions: { _doc_controller: cr }`.
- federal-parity and admin-bypass tests unchanged — the WORM mask
still strips w/d/a and admins still bypass.
All Go tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The last hardcoded client-side knowledge of the canonical convention
was the upload-zone regex in browse:
var UPLOAD_SCOPES = /\/(working|staging|incoming)(\/|$)/i;
Now declared in the cascade:
Schema:
drop_target: true|false leaf-only; describes THIS dir
(not propagated to descendants)
Lookup:
zddc.DropTargetAt(root, dir) bool
Surfaced to clients:
Directory listings carry an X-ZDDC-Drop-Target: true response
header when the cascade declares this leaf as an upload zone.
No header = no drop target.
Defaults populated:
working / working/* / staging / archive/<party>/incoming
all carry drop_target: true. Operators can extend (e.g. drop
files on archive/<party>/received via override) or disable
(e.g. drop_target: false at a specific staging subtree) without
touching code.
Browse migration:
loader.fetchServerChildren reads the response header and stamps
state.scopeDropTarget on every listing fetch. upload.js's
currentScopeAllows now reads that flag instead of regex-
matching the URL. Initial value is false in init.js so a
listing failure (offline / server doesn't emit the header)
safely defaults to "no drop zone".
Phase 4a closes the most visible asymmetry between server-side and
client-side cascade knowledge. The remaining client hardcodes
(browse grid-mode regex, archive source heuristics, shared/nav
stage strip) follow the same pattern when needed — Phase 4b/c/d.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final consumer migration. The Go-coded lists that previously encoded
the ZDDC convention all defer to the .zddc cascade now.
Schema added:
available_tools: [tool1, tool2, ...] concat-union across cascade;
tools not in the union are
denied auto-route at that path
auto_own_fenced: true|false generated auto-own .zddc
carries inherit:false (private
to creator)
Lookups added:
AvailableToolsAt(root, dir) union of available_tools across cascade
IsToolAvailableAt(root, dir, tool)
AutoOwnFencedAt(root, dir) leaf-only
Cascade semantics finalised (per field):
default_tool → leaf→root walk (parent applies to descendants)
available_tools → leaf→root union (each level adds; baseline at root)
auto_own → leaf-only (creating THIS dir specifically)
auto_own_fenced → leaf-only (same)
virtual → leaf-only (THIS dir is virtual, not subtree)
Consumers migrated:
apps.DefaultAppAt → zddc.DefaultToolAt
apps.AppAvailableAt → zddc.IsToolAvailableAt (+ landing special)
EnsureCanonicalAncestors → AutoOwnAt + AutoOwnFencedAt
fs.ListDirectory empty-list fallback → zddc.IsDeclaredPath
fs.virtualCanonicalFolders → zddc.ChildrenDeclaredAt
dispatcher canonical-folder branches → unified into one
cascade-declared block
Hardcoded helpers REMOVED (dead code):
apps.inAncestorWithName
zddc.autoOwnDepthMatch / isAutoOwnDepthMatch
Hardcoded lists kept as data sources for the cascade walker but
no longer drive routing logic:
ProjectRootFolders / PartyFolders / AutoOwnCanonicalNames /
VirtualOnlyCanonicalNames / IsProjectRootFolder / IsArchivePartyFolder /
IsArchivePartyMdlDir — all still defined; only `ProjectRootFolders`
is used by special.go's IsProjectRootFolder. The rest are dead.
Dispatcher unified: the previously-two branches (per-party folder vs
project-root folder) collapse into one cascade-declared-path block
that handles the slash/no-slash convention uniformly:
- no-slash, default_tool=tables → ServeTable (default-MDL fallback)
- no-slash, default_tool set → apps.Serve(tool)
- no-slash, no default_tool → 302 to slash form
- slash, any → ServeDirectory empty-list fallback
The IsDir branch's switch also un-hardcoded — any cascade tool is
served (not just the legacy 3 names), so e.g. /Project/archive/<party>
/incoming (no slash) now serves classifier directly rather than 302'ing
to the slash form.
defaults.zddc.yaml populated with the canonical convention as the
recipe. Operators edit it (or override per-directory on disk) to
change any behaviour — no Go code changes required.
Browse drag-drop scope (working/staging/incoming) is the one remaining
client-side hardcoded regex; cascading that requires the cascade JSON
to be served to the client, which is its own Phase 4 piece.
Tests updated for the new no-slash mdl URL convention (landing MDL
card test) and no-slash stage URLs (nav strip test). All 248
Playwright + all Go tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema:
- default_tool: string (tool name served at this dir's no-slash URL)
- auto_own: *bool (mkdir post-hook auto-grants the creator)
- virtual: *bool (never materialise on disk; aggregator routes)
defaults.zddc.yaml: populated with the full canonical convention via
paths:. Top-level "*" matches any project; nested archive/working/
staging/reviewing declare the project-stage tools; archive's "*" /
mdl|incoming|received|issued tree declares the per-party surfaces.
All four party folders and all four project-root folders get their
default_tool; working / staging / archive/<party>/incoming get
auto_own; reviewing / archive/<party>/mdl get virtual. None of these
need on-disk dirs to exist.
Lookups (zddc/internal/zddc/lookups.go):
DefaultToolAt(root, dir) → cascade-resolved default tool name
AutoOwnAt(root, dir) → does mkdir auto-own here?
VirtualAt(root, dir) → never materialise on disk?
IsDeclaredPath(root, dir) → does the cascade say anything about this dir?
ChildrenDeclaredAt(root, dir)→ literal child names declared by Paths
Each looks up via EffectivePolicy → leaf level → Embedded fallback,
so operators' on-disk overrides win and the embedded baseline carries
the convention.
Tests cover the embedded convention, operator overrides, and
inherit:false blocking the embedded layer. No consumer migration yet
— that's Phase 3b. Behaviour is bit-identical for current callers
since none of them consult the new lookups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the recursive paths: schema and the cascade walker that threads
ancestor virtual contributions through to descendant levels.
Schema:
paths:
"*": # literal-segment or "*" segment-wildcard key
paths: # recursive — each step matches one segment
archive:
paths:
"*":
paths:
incoming:
title: "demo"
Each on-disk .zddc and the embedded defaults can declare paths:; the
walker collects every matching subtree and merges its contributions
into chain.Levels[depth] using mergeOverlay (per-field overlay with
on-disk most specific). The matched glob descends one segment at a
time; the value's own paths: becomes a new virtual source for deeper
matches.
Semantics:
- matchGlob: literal key first (case-insensitive on segment),
"*" wildcard fallback.
- mergeOverlay: top wins per-field on scalars; maps merge key-by-
key with top overriding; lists concat-dedupe; Paths replaces
(recursive walker threads it through naturally).
- inherit:false at any on-disk level drops accumulated ancestor
virtual sources AND zeroes chain.Embedded — the operator owns
every rule from that level outward.
- Behaviour is bit-identical when no .zddc declares paths:; the
walker reduces to the prior linear cascade.
Eight new tests cover the glob match table, ancestor-paths
contribution, on-disk-wins override, paths-absent bit-identical
behaviour, and inherit:false dropping ancestor paths: contributions.
All existing tests still pass.
Phase 3 next: populate defaults.zddc.yaml with the canonical
ZDDC convention via paths:, and replace apps.DefaultAppAt /
AppAvailableAt / AutoOwnCanonicalNames / VirtualOnlyCanonicalNames /
IsProjectRootFolder / IsArchivePartyFolder with cascade lookups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First step of the .zddc-first-configuration rollout: pure plumbing
that makes the future move-everything-out-of-Go work mechanically
possible without changing any current behaviour.
New pieces:
1. zddc/internal/zddc/defaults.zddc.yaml — a real YAML file in the
repo. Single source of truth for the baked-in baseline; intentionally
minimal in Phase 1 (just title + empty acl) so existing deployments
stay bit-identical until Phase 2 starts populating the schema.
2. //go:embed (defaults.go) bakes the bytes into the binary so
shipped deployments don't need the file. Operators who want a
starting point export with:
zddc-server show-defaults > /var/lib/zddc/root/.zddc
3. PolicyChain gains an Embedded ZddcFile field. EffectivePolicy
layers in the embedded defaults as a baseline below the on-disk
chain. Consumers that want the full effective view consult both;
existing consumers that only read chain.Levels keep working
bit-identically (the new field is additive).
4. New top-level `inherit:` key on ZddcFile. Default true. Set
`inherit: false` on any on-disk .zddc to zero out chain.Embedded
— the operator owns every rule from that level outward. Useful at
the on-disk root to fully reject the embedded defaults; useful at
deeper levels for sandbox subtrees.
5. `zddc-server show-defaults` (also accepts --show-defaults) subcommand
dumps the embedded bytes to stdout — same shape as --print-rego.
No flag plumbing needed beyond the existing args walk.
6. Tests: parse-roundtrip on the embedded file, presence in chain by
default, inherit:false drops it, explicit inherit:true is a no-op
versus the default.
Phase 2 (next): add a `paths:` recursive map + `default_tool:` /
`auto_own:` / `virtual:` keys, populate defaults.zddc.yaml with the
canonical ZDDC convention, and migrate apps.DefaultAppAt /
AutoOwnCanonicalNames / VirtualOnlyCanonicalNames to cascade lookups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
Adds internal/apps/ package serving the five tool HTMLs at virtual paths
based on the surrounding folder name convention:
archive every directory (multi-project, project, archive, vendor)
classifier any Incoming/Working/Staging directory and subtree
mdedit any Working directory and subtree
transmittal any Staging directory and subtree
landing only at deployment root
The current-stable build of every tool is //go:embed'd into the binary
at compile time — that's the default with zero config. Operators
override per-directory via .zddc apps: entries; closer-to-leaf wins.
Spec syntax (in any apps: value):
stable / beta / alpha / :stable channel
v0.0.4 / v0.0 / v0 / :v0.0.4 version
https://my-mirror/releases URL prefix only
https://my-mirror/releases:beta URL prefix + channel
https://my-fork/archive.html terminal full URL
./local.html / /abs/path.html terminal local path
The special apps.default key provides a baseline URL prefix and channel
inherited by any app not overridden per-name. Per-axis cascade: a deeper
.zddc can override the URL, the channel, or both.
Cascade walks root→leaf; default applies first at each level, then the
per-app entry. Terminal sources (paths and full .html URLs) short-circuit
composition; deeper non-terminal entries override parent terminals.
URL sources fetch once on first request and cache forever in
<ZDDC_ROOT>/_app/<host>/<path> — different upstreams with the same
filename stay distinct. No background refresh, no SHA-256 verification:
operators delete the cache file to force a refetch. Concurrent misses
for the same source dedupe via a 30-line hand-rolled singleflight.
Per-request override: any user can append ?v=<spec> to a tool URL
(e.g. ?v=beta, ?v=v0.0.4, ?v=:alpha, ?v=https://mirror/releases:beta)
to ask for a different build for one request. Security: ?v= serves
ONLY versions already in the cache (cache miss returns 404; path
sources are rejected outright with 400). Users cannot trigger
arbitrary upstream fetches via crafted URLs.
Failed URL fetches (network down, 5xx) fall back to embedded with a
one-time WARN log. The X-ZDDC-Source response header reports what
served: fetch:URL / cache:URL / path:/abs / embedded:<app>@<build>.
Wire-in (cmd/zddc-server/main.go): dispatch routes <dir>/<app>.html
through apps.MatchAppHTML + AppAvailableAt + apps.Server.Serve when
no real file exists. Direct URL access to /_app/... is blocked at
the dispatch layer — cached files must go through the apps resolver
so they get correct Content-Type and ACL gating.
Schema (internal/zddc/file.go): ZddcFile gains Apps map[string]string
for cascade overrides. Validator (internal/zddc/validate.go) accepts
the special "default" key alongside the five canonical app names and
all spec forms.
Removes ZDDC_APPS_* env vars (no admin UI, no refresh interval, no
upstream allow-list — the simpler model has fewer knobs).
40+ unit tests across the new package: parser shapes, cascade
resolution with default+per-app interactions, terminal short-circuit
semantics, ?v= cache-only enforcement, embedded fallback, atomic
cache writes, singleflight dedup. Plus end-to-end dispatch tests in
cmd/zddc-server/main_test.go.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generalize the admin model from "single root super-admin" to a
delegated chain: a `<dir>/.zddc/admins` list grants admin authority
for that subtree, with a strict-ancestor rule preventing
self-elevation (you cannot edit the .zddc that grants your own
authority — only files strictly below it).
Add a guided server-rendered editor at /.admin/zddc/edit?path=<dir>
so subtree admins can manage their fiefdoms without filesystem
access. JSON API at /.admin/zddc covers GET (file + effective chain
+ can_edit), POST (atomic write + cache invalidation), DELETE,
plus a /tree endpoint listing every .zddc visible to the caller.
Optional theming via <root>/.admin.css.
Validation: glob syntax check, root-self-demotion rejection,
reserved-prefix path guard, YAML round-trip sanity. Writes are
atomic (temp file + fsync + rename) and invalidate the policy
cache.
Also includes the prior in-flight `Title` field on ProjectInfo
so per-project .zddc titles surface on the landing-page picker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three improvements bundled because they all ship as zddc-server v0.0.2:
* /.admin/ debug dashboard with /whoami, /config, /logs sub-routes.
Authorization via a top-level `admins:` glob list in <ZDDC_ROOT>/.zddc
(root-only — subdir entries deliberately ignored to prevent privilege
escalation via subtree write access). Non-admin requests get 404 so the
page is invisible. Recent logs surface via a 500-entry slog ring buffer
teed off the existing TextHandler. Lets operators debug without
kubectl exec.
* Default ZDDC_EMAIL_HEADER changes from `X-Email` to
`X-Auth-Request-Email` — the oauth2-proxy / nginx auth-request
convention that the TND helm chart already sets explicitly.
Operators who set the env var explicitly are unaffected; deployments
relying on the previous default need to set ZDDC_EMAIL_HEADER=X-Email
or update their proxy.
* dispatch() rejects any URL whose segments contain a dot prefix other
than the recognized virtual prefixes (.admin, cfg.IndexPath /
.archive). Matches the existing listing-pipeline filter so hidden
subtrees on the served PVC (e.g. /srv/.devshell — used by the
in-cluster dev-shell for persistent home-dir state) become
unreachable via direct HTTP fetch, not just hidden in listings.
Refreshes the X-Email reference in website/index.html accordingly.
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.