ZDDC/zddc/GRAMMAR.md
2026-06-11 13:32:31 -05:00

232 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/<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 `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 `<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 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.