12 KiB
The .zddc grammar
This is the authoritative reference for the .zddc policy language: every key,
its type, how it composes across the cascade, and how the engine turns a tree of
.zddc files into an access decision. The design intent is policy-as-data:
operators express all per-folder behaviour in .zddc; zddc-server is the
enforcement engine that reads and applies it. Nothing here is hardcoded to
folder names — the embedded defaults (the per-depth tree under
internal/zddc/defaults/, exported as a .zddc.zip by show-defaults) are
just the bottom-most policy in the cascade and are fully overridable.
Status: the declarative grammar below is complete and enforced today. A future sandboxed expression layer (
when:conditions — see §7) is reserved for attribute-based rules that data alone can't express; it is not yet implemented.
The grammar has two executable backings, kept in lockstep with this document:
- Layer 1 — the engine enforces whatever a
.zddcsays (storage-agnostic):internal/policy/policy_test.go(cascade scenarios) +internal/zddc/{acl, roles,worm}_test.go+internal/policy/parity_test.go(Go ↔ OPA/Rego parity). - Layer 2 — the shipped defaults are correct:
internal/handler/ defaults_matrix_test.go(role × canonical-path × verb truth table).
1. Document model
A .zddc file is YAML. A directory's effective policy is the cascade: the
ordered chain of .zddc files from the deployment root down to that directory,
plus the embedded defaults at the bottom. Levels are indexed root (0) → leaf
(last).
Four things contribute to a level beyond the on-disk file:
- Embedded defaults — the per-depth tree under
internal/zddc/defaults/(assembled into a nestedpaths:ZddcFile), always the bottom of the chain (unless fenced off; seeinherit). .zddc.zippolicy bundles — a.zddc.zipat any directory mounts a policy subtree there: its members (paths with*wildcards) are resolved likepaths:and merged UNDER the on-disk.zddc. Withinherit:false+acl.inherit:falsein its root member it becomes a self-contained island. The embedded defaults are simply the bundle mounted at the deployment root.- Virtual
paths:contributions — an ancestor'spaths:tree injects policy at descendant directories that need not exist on disk. This is why a brand-new project resolves usable policy at every canonical URL. - The on-disk
.zddcat the directory itself, which wins per-field over the virtual/ancestor contributions.
Because policy is resolved for virtual paths, every authorization decision
must compute the chain at the target's own directory — never at the nearest
directory that happens to exist on disk. (Resolving at the nearest existing
ancestor was the cause of a real bug: a document_controller was denied
create under a not-yet-materialised working/<party>/ because the chain was
computed at the project root.)
2. The decision pipeline
A single verb decision (r read, w write, c create, d delete, a admin)
is computed in this fixed order — see internal/policy/policy.go
(InternalDecider.Allow):
1. Active-admin bypass → if the principal is an ELEVATED admin named in an
admins: list anywhere on the chain, ALLOW everything
(WORM included). The single escape hatch.
2. WORM mask → if the directory is in a worm: zone, the effective
verbs are (normal-grant & r) | (worm-grant & rc):
write/delete/admin always stripped; create survives
only for worm: members; read via either source.
3. Normal cascade grant → otherwise, the cascade ACL decision (§4).
Read/elevation rules that frame the pipeline:
- Elevation is required for admin powers (sudo-style). A principal is an
active admin only when
Elevatedis true AND named (directly or via role) in anadmins:list on the chain. Browser sessions elevate via thezddc-elevate=1cookie (set by?admin=true); bearer-token callers are elevated implicitly. - Default-allow only on a truly empty tree: if no
.zddcexists anywhere on the chain (HasAnyFile == false), everything is allowed (a bare directory served with no policy is public). As soon as any.zddcexists, the default is deny.
3. ACL evaluation (the acl.permissions core)
acl.permissions maps a principal pattern → a verb string. A principal
is an email-glob (alice@x, *@acme.com, *) or a role name (no @),
which expands to the role's members (§roles). Verbs are any subset
of r w c d a; the empty string "" is an explicit deny.
Two composition rules — and they are different on purpose:
- Within one level, every matching entry (email + wildcard + role) is
unioned — EXCEPT an explicit-deny (
"") match, which zeroes the grant at that level (deny is more specific than a permissive role membership). - Across levels, the deepest level that matches the principal wins — it
replaces, it does not add. A closer
.zddcthat grants youcroverrides an ancestor'sr; an ancestor grant is consulted only if no closer level matches you at all.
Mnemonic: role membership unions up the tree; permissions take the deepest match. Conflating these is the most common reasoning error.
4. Key reference
✱ = honored only in the root .zddc. Cascade column says how the key
composes from leaf→root.
| Key | Type | Cascade | Meaning |
|---|---|---|---|
acl.permissions |
map principal→verbs | deepest match wins (§3) | the verb grants |
roles |
map name→{members:[], reset?} |
members union (a reset:true level starts fresh) |
named principal groups; referenced by acl/worm/admins |
admins |
list of principal | union (root = super-admin; deeper = subtree admin) | elevation-gated full bypass over that scope |
worm |
list of principal | union | WORM zone: strips w/d/a for all; create survives only for listed (§2) |
inherit |
bool (default true) | fence | false stops the cascade here — nothing below (incl. defaults) is visible |
default_tool |
string | deepest non-empty | tool served at <dir> (no slash) — sugar for views.dir.tool |
dir_tool |
string | deepest non-empty | tool served at <dir>/ — sugar for views.dir_slash.tool |
views |
map shape→{tool,config} |
map-merge (child wins per shape) | per-URL-shape tool + .zddc.d/-resolved config |
available_tools |
list | concat-dedupe union | tools the apps subsystem may auto-serve |
auto_own |
bool | deepest non-nil | mkdir post-hook writes a creator-owned .zddc |
auto_own_fenced |
bool | deepest non-nil | the auto-own .zddc is inherit:false (private to creator) |
auto_own_roles |
list | deepest non-nil | roles also granted rwcda in the auto-own .zddc |
history |
bool | subtree-inheriting (deepest set wins; crosses fences) | snapshot markdown edits to .history/ |
history_globs |
list | deepest non-empty (default ["*.md"]) |
which files history applies to |
drop_target |
bool | leaf-only | this dir is a browse drag-drop upload zone |
virtual |
bool | deepest non-nil | never materialise on disk; treat as virtual route |
party_source |
string | leaf-only | a new <party>/ here requires registration in <source>/<party>.yaml |
convert |
{client,project,contractor,project_number} |
per-key latest (leaf) wins | MD→{docx,html,pdf} template variables |
field_codes |
map name→FieldCode | map-merge per code | tracking-number / record field vocabularies |
records |
map pattern→RecordRule | map-merge per pattern, per-field | per-record-type rules (filename format, defaults, locked, row scope) |
display |
map name→label | leaf-only (no upward cascade) | human labels for child entries |
tables |
map stem→specpath | leaf-only | legacy directory-of-YAML table view |
received_path |
string | leaf | links a workflow folder back to its canonical submittal |
planned_review_date / planned_response_date |
ISO date | leaf | doc-controller commitments on the canonical submittal |
title |
string | leaf | human title for the directory |
paths |
map segment→.zddc |
recursive virtual injection (on-disk wins) | apply policy at descendant segments that need not exist |
FieldCode and RecordRule are themselves small grammars (discriminated unions
/ structured rules) — see the Go types in internal/zddc/file.go for their
sub-fields; they are out of scope for this top-level reference.
5. paths: — virtual policy injection
paths: is what makes the grammar express a whole project shape from one file.
Each key is a single path segment — a literal name or * (matches any
segment) — and the value is a nested .zddc applied at the matching child
directory. It recurses (paths: inside paths:). Matching prefers a literal
key, then falls back to *.
Ancestor paths: contributions are merged into the effective .zddc at each
level by EffectivePolicy; an on-disk .zddc at the matching directory wins
per-field. The embedded defaults use exactly this to define the canonical
project structure (archive/, working/, staging/, …) without any of those
folders existing on disk.
The merge trap. When you add a new top-level key, the per-level merge in
walker.go(mergeOverlay) must carry it frompaths:contributions intochain.Levels, or the key silently no-ops at default-driven paths. Verify a new key with a defaults-path test, not just a hand-builtPolicyChain.
6. Reserved namespaces (not policy keys)
.zddc.d/— per-directory admin-only reserve (tokens, logs, history, converted cache, view configs). 404 to non-admins at every depth; writes admin-gated. Not addressable as content..zddc.zip— a config bundle droppable at any directory. Its.zddcmembers (per-depth,*wildcards, individually replaceable) mount a policy subtree at that directory (see §1); it may also carry tool-HTML overrides. The shipped baseline is the embedded bundle at the deployment root (show-defaultsexports it). Browsable only by an elevated admin. NOT a cascade key — it's resolved by the engine, not declared inside a.zddc.
7. Extension point: when: expressions
The declarative keys above are attribute-free — they decide on principal + path + folder behaviour, not on runtime values. Attribute-based rules ("allow write only before the issue date", "creator may edit their own files", "requester.dept must equal folder.dept") are the one class data can't express.
The reserved mechanism is a pure, sandboxed expression attached to a grant — NOT embedded general-purpose code. Sketch (subject to design):
acl:
permissions:
"*@acme.com":
verbs: rwc
when: 'request.now < record.issue_date && request.email == record.author'
Constraints that keep the engine an engine:
- A single boolean expression in a non-Turing-complete, side-effect-free
language (CEL or
expr-lang), evaluated in-process per decision. - Inputs are a fixed, read-only context (
request,principal,record,folder) — no I/O, no filesystem, no network, no unbounded loops. - A timeout + recursion bound; a failed/erroring expression denies.
- This is distinct from raw JavaScript in
.zddc, which is deliberately not adopted: it would put arbitrary code execution in the authorization path and break the ".zddcis data" guarantee.
For operators who want to replace the decision engine wholesale, the
Decider interface already supports an external OPA/Rego policy server
(--policy-url); parity_test.go keeps the built-in engine and the Rego policy
in agreement. when: is the lightweight in-tree complement to that heavy
out-of-process option.
8. Validation
internal/zddc/validate.go (ValidateFile) checks a .zddc structurally
(known keys, verb charset, role/view shapes, path-safety of configs). The browse
client mirrors a subset in browse/js/preview-yaml.js. Formalizing these two
into one machine-readable schema (JSON Schema, validated server-side via
internal/jsonschema and shipped to the client) is the natural next step —
it removes the client/server drift and makes this document's §4 table
machine-checkable. Until then, this file is the source of truth and the two
test layers (above) are its behavioural guarantee.