ZDDC/zddc/internal/zddc/defaults.zddc.yaml
ZDDC 882d5e4c86 feat(zddc-server): server-stamped audit + history for record YAMLs
Adds cascade-driven schema + immutable audit history for the three table-style
record stores (mdl, rsk, ssr). Two new .zddc top-level keys carry the rules:

- field_codes: discriminated-union vocabulary (kind: enum|pattern|free) for
  the components used to compose tracking-number filenames and constrain
  record bodies. Map-merge across the cascade, mirror of apps: semantics.
- records: per-pattern rules (filename_format, field_defaults, locked,
  row_field, row_scope_fields). Filename-pattern scoping lets the SSR rule
  live at the party-folder level without bleeding onto mdl/rsk siblings.

PUTs to record YAML files route through a new WriteWithHistory orchestrator
(internal/handler/history.go) which:
- strips six client-supplied audit fields (created_at/by, updated_at/by,
  revision, previous_sha) so the client can't forge them
- validates body values against the cascade-resolved field_codes
- enforces filename_format composition (URL basename must match body fields)
- checks locked: defaults (422 mismatch)
- archives prior bytes to <dir>/.history/<base>/<RFC3339Nano>-<sha8>.<ext>
- stamps server-managed audit fields and writes the live file

History-before-live ordering preserves the prior version even on mid-write
crash. previous_sha forms a hash chain across revisions for tamper evidence.

The embedded defaults.zddc.yaml now declares records: entries for mdl, rsk,
and ssr.yaml. RSK rows carry the table-tracking components + row sequence
(filename = <table-tracking>-<row>); MDL rows compose to their own
tracking number; SSR records' identity is the party folder name.

GET <record>.yaml?history=1 returns a JSON list of prior revisions, ACL
gated identically to the live record. dot-segment rejection in
resolveTargetPath protects .history/ from direct client writes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:48:58 -05:00

324 lines
16 KiB
YAML

# defaults.zddc — embedded baseline configuration for every ZDDC
# deployment. Baked into the binary via //go:embed in defaults.go,
# loaded as the bottom-most level of the cascade. Operators override
# at the on-disk root /.zddc (or any deeper level); to ignore this
# file entirely, set `inherit: false` on an on-disk .zddc.
#
# To export an editable copy for an operator:
#
# zddc-server show-defaults > /var/lib/zddc/root/.zddc
#
# That places this file at the on-disk root, where the operator can
# edit it freely. The new file then takes the place of the embedded
# one (both contribute to the cascade, on-disk wins per-field).
title: "ZDDC"
# Empty acl at this layer — rules come from on-disk .zddc files above.
# A deployment with no on-disk root .zddc grants no access (consistent
# with prior behaviour); operators bootstrap by editing the root file.
acl:
permissions: {}
# ── Standard roles ─────────────────────────────────────────────────────────
#
# Two roles ship empty (no members) — a fresh deployment grants
# nothing until an operator populates them. They're referenced by the
# project-scoped grants in paths: below.
#
# Role membership UNIONS across the cascade: an on-disk .zddc that
# defines `project_team` again with one extra member ADDS that member
# to the inherited role. To start fresh at a subtree (e.g. a project
# wanting its own team independent of a deployment-wide default), use
# `reset: true` on the role at that level — ancestor definitions above
# the reset are then excluded.
#
# document_controller — the people who file into archive/<party>/
# received/ and issued/ (WORM zones). They get read+write-once-
# create there (via the worm: lists below) and read/write
# elsewhere in a project, plus subtree-admin of working/ and
# staging/ so they can stand up new top-level folders and manage
# user/staging subtrees. They are NOT subtree-admin of archive/,
# so the WORM constraint still binds them in received/issued.
#
# project_team — everyone working on a project. Read-only across
# the project. Their own working/<email>/ home and anything they
# create under incoming/ get a creator-owned auto-own .zddc
# (rwcda) which wins via deepest-match, so "read-only except
# what I own" falls out of the cascade with no special rule.
roles:
document_controller:
members: []
project_team:
members: []
# Universal tool baseline. archive (record browser), browse (file
# tree, hosts the in-place markdown editor), and landing (project
# picker) work everywhere. Each canonical folder below adds its own
# context-specific tools (transmittal in staging/, etc.). The cascade
# unions available_tools across all levels — leaf restrictions don't
# drop ancestor entries — so this baseline propagates to every
# descendant.
available_tools: [archive, browse, landing]
# ── The slash / no-slash routing convention ────────────────────────────────
#
# Every directory URL has two forms, each served by a configurable
# tool:
#
# <dir>/ (trailing slash) → `dir_tool` — the directory view.
# Defaults to `browse` (file-tree
# navigator). This is the site-wide
# default; you rarely set it.
# <dir> (no slash) → `default_tool` — the "specialized
# app" for this folder (e.g. archive,
# transmittal, tables). If a folder
# declares no default_tool, the no-
# slash form just 302s to the slash
# form, so you land on `dir_tool`.
#
# JSON listing requests are unaffected by either key — they always get
# the raw directory listing, so the browse SPA (and any other client)
# can enumerate entries no matter what dir_tool/default_tool are.
#
# Both keys cascade leaf→root: a parent's default_tool applies to
# descendants unless a deeper level overrides it (browse set on
# working/ reaches working/alice/notes/ for free). The keys below set
# default_tool on the canonical folders; dir_tool is left unset
# everywhere, so the slash form is always `browse`.
#
# ── Canonical project structure ────────────────────────────────────────────
#
# Every ZDDC project lives at a top-level directory. Under it the
# convention is four canonical folders: archive (formal record),
# working (in-progress workspace), staging (outbound prep), reviewing
# (Plan-Review-managed draft workspaces). Under archive/<party>/ the
# convention is four more: mdl (deliverables list), incoming (counterparty
# drop zone), received (immutable submittals), issued (immutable responses).
#
# All of this is expressed via the recursive paths: schema. None of
# the directories need to exist on disk — the cascade walker resolves
# behaviour from this declaration, so a fresh project lands on
# usable empty views at every well-known URL.
#
# Operators override any of this by mirroring the structure in an
# on-disk .zddc and changing what they need; on-disk values win.
paths:
# First segment under root is the project name; "*" matches any.
"*":
# Project-scoped baseline ACL. project_team gets read across the
# project; document_controller gets read + overwrite-existing
# (so people can ask them to fix a stuck file). Neither gets
# `c` (create) at this level — that's granted only at the
# specific spots below (archive/, working/, staging/), so the
# doc controller can't make arbitrary folders. Grants here cap
# at deeper levels per deepest-match-wins, except where a deeper
# .zddc restates a fuller grant for the same principal.
acl:
permissions:
project_team: r
document_controller: rw
# Plan Review composite endpoint: the doc controller right-clicks
# archive/<party>/received/<tracking>/ in the browse app and gets
# a "Plan Review" item that scaffolds workflow folders under the
# paths below. Both keys required; omitting the block disables
# the menu item for this subtree.
on_plan_review:
reviewing_root: reviewing/
staging_root: staging/
paths:
archive:
default_tool: archive
# The doc controller can create party subfolders here
# (archive/<party>/). Restate the full grant — deepest-match
# is per-principal replacement, so we re-list rw + add c.
acl:
permissions:
document_controller: rwc
paths:
# Second segment under archive/ is the party name.
"*":
# When the doc controller creates a party folder, an
# auto-own .zddc grants them rwcda there (UNFENCED — so
# the project-level project_team:r still cascades through
# to received/issued). That lets them set up the
# counterparty's own .zddc afterward.
auto_own: true
# SSR record: the party folder's ssr.yaml carries this
# party's vendor / contract / status data. Scoped by
# filename pattern so the lock on `kind` only applies to
# ssr.yaml — the mdl/, rsk/, received/ subfolders are
# untouched. No filename_format because identity is the
# party folder name, not a composed tracking number.
records:
"ssr.yaml":
field_defaults:
kind: SSR
locked: [kind]
paths:
mdl:
default_tool: tables
available_tools: [tables]
# The mdl folder is virtual by convention — the
# tables tool serves it from the embedded default
# spec even when the on-disk folder doesn't exist.
virtual: true
# MDL records: each .yaml file is an independent
# deliverable with its own composed tracking number.
# No locks — the row's body fields drive the
# filename, type is free-choice from the deployment's
# field_codes. Operators define field_codes at the
# project root (or higher) to supply the originator /
# discipline / type / sequence vocabularies.
records:
"*.yaml":
filename_format: "{originator}-{phase?}-{project}-{area?}-{discipline}-{type}-{sequence}{suffix?}"
rsk:
default_tool: tables
available_tools: [tables]
# Risk register — same virtual-by-convention pattern
# as mdl/. Embedded default-rsk spec backs it when no
# operator override is on disk.
virtual: true
# RSK records: each .yaml file is a row of a parent
# rsk-type deliverable. The table itself has a
# tracking number (same shape as an MDL deliverable
# with type=RSK); rows append a -{row} suffix. The
# server auto-assigns row within the row-scope group
# on POST-create.
records:
"*.yaml":
filename_format: "{originator}-{phase?}-{project}-{area?}-{discipline}-{type}-{sequence}{suffix?}-{row}"
field_defaults:
type: RSK
locked: [type]
row_field: row
row_scope_fields: [originator, phase, project, area, discipline, type, sequence, suffix]
incoming:
# incoming/ is the COUNTERPARTY's drop zone. The flow:
# 1. the other party's document controller uploads
# files here (a deployment grants them cr at this
# path, e.g. acl: { permissions: { "*@acme.com": cr } }
# at archive/Acme/incoming/.zddc — or they mkdir a
# dated subfolder under incoming/ and own it via
# auto_own)
# 2. OUR document controller QCs them via classifier
# (rename in place) and moves them to received/
# (which needs delete here + worm-create there),
# ideally returning a signed transmittal in issued/
#
# The normal project_team has only read here (inherited
# from the project level — they have no c/w) so they can
# see what's been dropped but not touch it. The
# document_controller grant restates rwcd so the QC +
# transfer-out workflow has the delete bit it needs.
default_tool: classifier
available_tools: [classifier]
auto_own: true
drop_target: true
acl:
permissions:
document_controller: rwcd
# received/ and issued/ are WORM (write-once-read-many).
# The `worm:` list marks the zone:
#
# - write (w) and delete (d) are stripped for EVERYONE
# - create (c) is stripped for everyone EXCEPT the
# principals listed — they get read + write-once-
# create ("cr")
# - read for non-listed principals is whatever the
# normal cascade ACL granted; the WORM list does not
# itself confer read to outsiders
# - admins (root / subtree) bypass entirely — the
# human escape hatch for mis-filed documents
#
# The baseline is an empty list: WORM zone, no
# create-capable principals — filing is locked until a
# deployment names a document controller, e.g.
#
# worm: ["doc-control@example.com"]
#
# at received/ (or issued/, or archive/<party>/, or
# wherever scopes it right). worm: lists UNION across the
# cascade, so a deeper .zddc adds more controllers.
received:
default_tool: archive
# document_controller may file write-once into the
# WORM zone. Their project-level rw is masked here
# to r; worm: restores write-once-create.
worm: [document_controller]
issued:
default_tool: archive
worm: [document_controller]
working:
default_tool: browse
available_tools: [browse, classifier]
# working/ auto-owns the first creator + the per-user homes
# below.
auto_own: true
drop_target: true
# Doc controller is subtree-admin of working/ — full create
# + manage, including taking over a fenced per-user home if a
# user leaves. (Scoped here, not at the project root, so the
# WORM constraint in archive/<party>/received|issued still
# binds them.)
admins: [document_controller]
paths:
"*": # per-user home dir
default_tool: browse
available_tools: [browse, classifier]
auto_own: true
# Per-user home is private by default: the generated
# auto-own .zddc carries inherit:false so ancestor ACL
# grants don't reach inside. The user can edit the file
# to grant collaborators access.
auto_own_fenced: true
drop_target: true
staging:
default_tool: transmittal
available_tools: [transmittal, classifier]
auto_own: true
drop_target: true
# Doc controller is subtree-admin of staging/ too — same
# rationale as working/.
admins: [document_controller]
reviewing:
default_tool: browse
available_tools: [browse]
# reviewing/ is the doc-controller's draft-workspace area. The
# "Plan Review" composite endpoint (see on_plan_review at project
# level) scaffolds a physical folder here for each submittal
# under review, with a .zddc carrying received_path back to the
# canonical submittal in received/. Subtree-admin so the doc
# controller can author per-folder .zddc files (originator ACL,
# planned_date).
auto_own: true
drop_target: true
admins: [document_controller]
# Project-level aggregation tables. All three are virtual: the
# folder doesn't exist on disk; the server synthesizes listings
# by walking archive/*/ at request time. ACL on each synthetic
# row is evaluated against the canonical archive/<party>/ path,
# so party owners can edit their own rows and non-owners see
# them read-only.
ssr:
default_tool: tables
available_tools: [tables]
# SSR aggregates one row per party folder; the row's backing
# file is archive/<party>/ssr.yaml. + Add row in this view
# creates a new party folder.
virtual: true
mdl:
default_tool: tables
available_tools: [tables]
# Project-rollup of every archive/<party>/mdl/ row. Read +
# edit; + Add row is disabled because party affiliation is
# ambiguous here (add at the per-party path instead).
virtual: true
rsk:
default_tool: tables
available_tools: [tables]
# Project-rollup of every archive/<party>/rsk/ row. Same
# semantics as the mdl rollup.
virtual: true