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

12 KiB
Raw Blame History

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 .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).
  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: 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). 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):

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.