# 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](#7-extension-point-when-expressions)) 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 `.zddc` says** (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: 1. **Embedded defaults** — the per-depth tree under `internal/zddc/defaults/` (assembled into a nested `paths:` `ZddcFile`), always the bottom of the chain (unless fenced off; see [`inherit`](#inherit)). 2. **`.zddc.zip` policy bundles** — a `.zddc.zip` at *any* directory mounts a policy subtree there: its members (paths with `*` wildcards) are resolved like `paths:` and merged UNDER the on-disk `.zddc`. With `inherit:false` + `acl.inherit:false` in its root member it becomes a self-contained island. The embedded defaults are simply the bundle mounted at the deployment root. 3. **Virtual `paths:` contributions** — an ancestor's [`paths:`](#paths) 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. 4. **The on-disk `.zddc`** at 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//` 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 `Elevated` is true AND named (directly or via role) in an `admins:` list on the chain. Browser sessions elevate via the `zddc-elevate=1` cookie (set by `?admin=true`); bearer-token callers are elevated implicitly. - **Default-allow only on a truly empty tree**: if *no* `.zddc` exists anywhere on the chain (`HasAnyFile == false`), everything is allowed (a bare directory served with no policy is public). As soon as any `.zddc` exists, 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`](#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 `.zddc` that grants you `cr` overrides an ancestor's `r`; 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 `` (no slash) — sugar for `views.dir.tool` | | `dir_tool` | string | deepest non-empty | tool served at `/` — 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 `/` here requires registration in `/.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 from `paths:` contributions into > `chain.Levels`, or the key silently no-ops at default-driven paths. Verify a > new key with a *defaults-path* test, not just a hand-built `PolicyChain`. --- ## 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 `.zddc` members (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-defaults` exports 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): ```yaml 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 "`.zddc` is 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.