docs(zddc): formal .zddc grammar reference
Consolidates the .zddc policy language — scattered today across ZddcFile struct
comments, defaults.zddc.yaml, and ARCHITECTURE.md — into one authoritative spec:
- document model + cascade (levels root→leaf, virtual paths:, fences) and the
rule that decisions resolve at the target's OWN dir (the bug class we hit);
- the decision pipeline: active-admin bypass → WORM mask → cascade ACL, plus
elevation + default-allow-on-empty-tree;
- ACL composition, with the two deliberately-different rules stated plainly
(role membership unions up the tree; permissions take the deepest match);
- a per-key reference table (type + cascade semantics + meaning) for all ~25
keys, including the mergeOverlay trap for adding new keys;
- reserved namespaces (.zddc.d, .zddc.zip);
- a reserved `when:` extension point for sandboxed, side-effect-free
expressions (CEL/expr-lang) — the safe alternative to raw JS, complementing
the existing OPA/Rego Decider seam;
- validation + the two executable backings (Layer 1 engine, Layer 2 matrix).
Policy-as-data: operators express behaviour in .zddc; the app enforces. Per the
chosen direction (formalize first; sandboxed expressions for the conditional gap).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bae8e1f79b
commit
a84bdfdc58
1 changed files with 222 additions and 0 deletions
222
zddc/GRAMMAR.md
Normal file
222
zddc/GRAMMAR.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# 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 (`internal/zddc/defaults.zddc.yaml`) are
|
||||
just the bottom-most `.zddc` 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)**.
|
||||
|
||||
Three things contribute to a level beyond the on-disk file:
|
||||
|
||||
1. **Embedded defaults** — `defaults.zddc.yaml`, always the bottom of the chain
|
||||
(unless fenced off; see [`inherit`](#inherit)).
|
||||
2. **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.
|
||||
3. **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` — project-root config bundle: tool-HTML overrides today, and the
|
||||
intended carrier for per-depth default `.zddc` files (individually
|
||||
replaceable) tomorrow. Browsable only by an elevated admin.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
Loading…
Reference in a new issue