feat(zddc): retire defaults.zddc.yaml; .zddc.zip is the policy carrier (phase 6)
Completes the migration. The embedded per-depth tree (internal/zddc/defaults/)
is now the sole source of the shipped baseline; defaults.zddc.yaml is deleted.
- EmbeddedDefaults() assembles the tree (no yaml). show-defaults now emits a
.zddc.zip (per-depth, "*" wildcard members) via EmbeddedDefaultsZip() —
operators redirect it to <ROOT>/.zddc.zip (or any directory) and edit/add/
delete individual members.
- Dropped EmbeddedDefaultsBytes; reworked the dumpable test to validate the
emitted zip; removed the now-redundant tree-vs-yaml oracle (the Layer-2
matrix is the ongoing behavioral guarantee, and it stays green).
- Swept stale "defaults.zddc.yaml" comment references to the embedded tree.
- GRAMMAR.md §1/§6 updated: .zddc.zip is a policy bundle mountable at ANY
directory (subtree mount; inherit:false + acl.inherit:false = island); the
shipped baseline is the embedded bundle at the root.
Net of the 6-phase migration: policy is per-depth .zddc files in a .zddc.zip
that an operator can drop at any level to override the cascade; the engine
(Assemble + the unchanged walker) enforces it. Full Go suite + matrix green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4681f2c358
commit
1e0e403f1e
24 changed files with 134 additions and 410 deletions
|
|
@ -5,8 +5,9 @@ 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.
|
||||
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
|
||||
|
|
@ -30,14 +31,20 @@ 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:
|
||||
Four things contribute to a level beyond the on-disk file:
|
||||
|
||||
1. **Embedded defaults** — `defaults.zddc.yaml`, always the bottom of the chain
|
||||
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. **Virtual `paths:` contributions** — an ancestor's [`paths:`](#paths) tree
|
||||
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.
|
||||
3. **The on-disk `.zddc`** at the directory itself, which wins per-field over
|
||||
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
|
||||
|
|
@ -168,9 +175,12 @@ folders existing on disk.
|
|||
- `.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.
|
||||
- `.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`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -52,14 +52,19 @@ func main() {
|
|||
fmt.Print(policy.FederalRego)
|
||||
return
|
||||
case "show-defaults", "--show-defaults":
|
||||
// Dump the embedded baseline .zddc to stdout. Pipe into a
|
||||
// real file (e.g. $ZDDC_ROOT/.zddc) to start from the
|
||||
// shipped defaults and edit; the on-disk copy then
|
||||
// participates in the cascade alongside the embedded
|
||||
// layer (both contribute; child wins). To ignore the
|
||||
// embedded layer entirely after exporting, set
|
||||
// `inherit: false` at the top of the exported file.
|
||||
_, _ = os.Stdout.Write(zddc.EmbeddedDefaultsBytes())
|
||||
// Emit the embedded baseline as a .zddc.zip (per-depth policy
|
||||
// tree, "*" wildcard members) to stdout. Redirect into a bundle
|
||||
// (e.g. `> $ZDDC_ROOT/.zddc.zip`) to start from the shipped
|
||||
// defaults and edit/add/delete individual members; the bundle
|
||||
// participates in the cascade (child wins). Drop it at any
|
||||
// directory to mount a subtree; add inherit:false +
|
||||
// acl.inherit:false to fully replace the baseline there.
|
||||
b, err := zddc.EmbeddedDefaultsZip()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "show-defaults:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_, _ = os.Stdout.Write(b)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -218,7 +223,7 @@ func main() {
|
|||
"no_auth", cfg.NoAuth)
|
||||
|
||||
// Bootstrap sanity: warn loudly (but don't fail) when the root .zddc
|
||||
// grants nobody anything. Embedded defaults.zddc.yaml ships with empty
|
||||
// grants nobody anything. Embedded internal/zddc/defaults/ ships with empty
|
||||
// role members, so a fresh deployment refuses every request until the
|
||||
// operator populates the file.
|
||||
warnIfNoBootstrap(cfg)
|
||||
|
|
@ -516,7 +521,7 @@ func setupApps(cfg config.Config) (*apps.Server, error) {
|
|||
}
|
||||
|
||||
// warnIfNoBootstrap fires a startup slog.Warn when the root .zddc grants
|
||||
// nobody anything — the embedded defaults.zddc.yaml ships with empty role
|
||||
// nobody anything — the embedded defaults ships with empty role
|
||||
// members, so a deployment without operator-populated admins / acl
|
||||
// permissions / role members refuses every request. Skipped under
|
||||
// --no-auth (auth disabled; warning would be redundant). Per-project
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
// AppAvailableAt reports whether app's virtual HTML can be served at
|
||||
// requestDir. Delegates to the .zddc cascade's available_tools union
|
||||
// (zddc.IsToolAvailableAt). The convention previously hardcoded here
|
||||
// now lives in defaults.zddc.yaml and is overridable per-directory
|
||||
// now lives in internal/zddc/defaults/ and is overridable per-directory
|
||||
// by operators.
|
||||
//
|
||||
// Operators can always drop a real <name>.html file at any path to
|
||||
|
|
@ -74,7 +74,7 @@ func AppAvailableAt(root, requestDir, app string) bool {
|
|||
// Phase 3b: delegates to zddc.DefaultToolAt, which resolves the
|
||||
// answer from the .zddc cascade (operator on-disk + embedded
|
||||
// defaults). The convention previously hardcoded in the switch
|
||||
// statement below now lives in zddc/internal/zddc/defaults.zddc.yaml
|
||||
// statement below now lives in zddc/internal/zddc/defaults/
|
||||
// and is overridable per-directory by operators.
|
||||
//
|
||||
// Project root itself (depth-1) still returns "" — the cascade
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
|||
// Empty-listing fallback for cascade-declared paths. A fresh
|
||||
// project doesn't have working/, staging/, reviewing/, or
|
||||
// archive/<party>/incoming/ on disk until something is
|
||||
// written into them — but the cascade (defaults.zddc.yaml
|
||||
// written into them — but the cascade (internal/zddc/defaults/
|
||||
// plus any on-disk overrides) declares them via paths:, so
|
||||
// the stage-strip / file nav can link unconditionally.
|
||||
// Returning [] gives a usable empty view (the tables peers
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package handler
|
|||
// Layer 2 — the SHIPPED DEFAULT POLICY contract.
|
||||
//
|
||||
// This is the executable truth table for the embedded defaults
|
||||
// (internal/zddc/defaults.zddc.yaml): role × canonical-path × verb → allow/deny.
|
||||
// (internal/zddc/defaults/): role × canonical-path × verb → allow/deny.
|
||||
// It pins the document-control access model so a change to the defaults — OR to
|
||||
// the engine that resolves them — can't silently alter who-can-do-what. (When
|
||||
// the defaults later move into a project-root .zddc.zip of per-depth .zddc
|
||||
|
|
|
|||
|
|
@ -807,7 +807,7 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Auto-ownership for the newly-created directory. The .zddc
|
||||
// cascade's `auto_own:` flag (see defaults.zddc.yaml) drives this,
|
||||
// cascade's `auto_own:` flag (see internal/zddc/defaults/) drives this,
|
||||
// same as EnsureCanonicalAncestors. A creator-owned .zddc lands
|
||||
// inside abs when:
|
||||
// - abs itself is declared auto_own (e.g. an explicit mkdir of
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseW
|
|||
}
|
||||
|
||||
// Resolve the cascade rule at slotAbs to pick a composed filename.
|
||||
// The defaults.zddc.yaml records: entries declare a "*.yaml" rule
|
||||
// The internal/zddc/defaults/ records: entries declare a "*.yaml" rule
|
||||
// for both mdl/ and rsk/ folders with filename_format pointing at
|
||||
// body fields; for RSK, the rule also carries row_field +
|
||||
// row_scope_fields so the server can assign the next row sequence
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ func IsZddcFileRequest(urlPath string) bool {
|
|||
//
|
||||
// Virtual: if it does not exist, the body is the cascade's
|
||||
//
|
||||
// leaf-level ZddcFile (what defaults.zddc.yaml's paths:
|
||||
// leaf-level ZddcFile (what internal/zddc/defaults/'s paths:
|
||||
// tree declares for THIS exact directory, plus any
|
||||
// virtual contributions threaded through by the walker)
|
||||
// marshalled as YAML. A header comment names the source
|
||||
|
|
@ -143,7 +143,7 @@ func ServeZddcFile(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// renderVirtualZddc produces a YAML body for a directory that has no
|
||||
// .zddc on disk. The body is the cascade's leaf-level ZddcFile —
|
||||
// i.e. what defaults.zddc.yaml's paths: tree declares for this exact
|
||||
// i.e. what internal/zddc/defaults/'s paths: tree declares for this exact
|
||||
// directory, plus any contributions the walker threaded through. The
|
||||
// goal is to expose the embedded defaults' source of truth: a new
|
||||
// user opening the virtual .zddc here sees, in the same yaml shape
|
||||
|
|
@ -164,7 +164,7 @@ func renderVirtualZddc(chain zddc.PolicyChain) (string, error) {
|
|||
var b strings.Builder
|
||||
b.WriteString("# Virtual .zddc — no file on disk at this directory.\n")
|
||||
b.WriteString("# The content below is what the embedded defaults\n")
|
||||
b.WriteString("# (defaults.zddc.yaml's paths: tree) declare for this\n")
|
||||
b.WriteString("# (internal/zddc/defaults/'s paths: tree) declare for this\n")
|
||||
b.WriteString("# exact path. Edit and save through the YAML editor in\n")
|
||||
b.WriteString("# browse to materialise a real .zddc here carrying your\n")
|
||||
b.WriteString("# changes; the bytes you save become the override\n")
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ func TestServeZddcFile_ExistingFile(t *testing.T) {
|
|||
// (project_team: r, observer: r, document_controller: rw) plus the
|
||||
// canonical paths: tree (archive, working, staging, reviewing, …).
|
||||
// Asserts a few load-bearing markers; the full content is the
|
||||
// `defaults.zddc.yaml` source-of-truth, which lives under
|
||||
// `internal/zddc/defaults/` source-of-truth, which lives under
|
||||
// zddc/internal/zddc and is parsed at every cascade walk.
|
||||
func TestServeZddcFile_VirtualDefault(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, erro
|
|||
}
|
||||
|
||||
// WORM zone: a directory whose cascade declares `worm:` (see
|
||||
// defaults.zddc.yaml — archive/<party>/received and issued carry
|
||||
// internal/zddc/defaults/ — archive/<party>/received and issued carry
|
||||
// `worm: {}`) is write-locked. Inside it, the effective verbs
|
||||
// for a non-admin principal are:
|
||||
//
|
||||
|
|
|
|||
|
|
@ -1,31 +1,58 @@
|
|||
package zddc
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// defaultsBytes is the legacy single-file embedded baseline. Retained only so
|
||||
// TestEmbeddedTreeMatchesYAML can prove the per-depth tree (the new source of
|
||||
// truth) assembles to exactly the same ZddcFile. Removed once that guarantee
|
||||
// is locked. (Still surfaced by EmbeddedDefaultsBytes / show-defaults for now.)
|
||||
//
|
||||
//go:embed defaults.zddc.yaml
|
||||
var defaultsBytes []byte
|
||||
|
||||
// defaultsTreeFS is the embedded per-depth default policy tree — the source of
|
||||
// truth. `all:` includes the `.zddc` (dot) files and `_any_` (underscore)
|
||||
// directories that a bare //go:embed would skip.
|
||||
// truth for the shipped baseline, the bottom of every cascade. `all:` includes
|
||||
// the `.zddc` (dot) files and `_any_` (underscore) directories a bare
|
||||
// //go:embed would skip. The `_any_` directory is the on-disk stand-in for the
|
||||
// "*" wildcard segment (kept out of literal "*" directories in the repo).
|
||||
//
|
||||
//go:embed all:defaults
|
||||
var defaultsTreeFS embed.FS
|
||||
|
||||
// EmbeddedDefaultsBytes returns the raw embedded defaults YAML. Surface: the
|
||||
// show-defaults CLI dumps these to stdout.
|
||||
func EmbeddedDefaultsBytes() []byte {
|
||||
out := make([]byte, len(defaultsBytes))
|
||||
copy(out, defaultsBytes)
|
||||
return out
|
||||
// EmbeddedDefaultsZip packages the embedded per-depth default policy tree as a
|
||||
// .zddc.zip with member paths using the "*" wildcard. The show-defaults CLI
|
||||
// emits this so an operator can drop it at <ZDDC_ROOT>/.zddc.zip — or any
|
||||
// directory — and edit, add, or delete individual members. Mounting it
|
||||
// (optionally with inherit:false + acl.inherit:false to fully replace the
|
||||
// baseline) is how a deployment customizes the shipped policy.
|
||||
func EmbeddedDefaultsZip() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
err := fs.WalkDir(defaultsTreeFS, "defaults", func(p string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
member := strings.ReplaceAll(strings.TrimPrefix(p, "defaults/"), AnyPlaceholder+"/", "*/")
|
||||
data, err := fs.ReadFile(defaultsTreeFS, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := zw.Create(member)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -35,8 +62,7 @@ var (
|
|||
)
|
||||
|
||||
// EmbeddedPolicyTree returns the baked-in per-depth default policy tree,
|
||||
// memoised. This is the embedded form of the .zddc.zip mounted at the
|
||||
// deployment root (the bottom of every cascade).
|
||||
// memoised. The embedded form of the .zddc.zip mounted at the deployment root.
|
||||
func EmbeddedPolicyTree() (PolicyTree, error) {
|
||||
embeddedTreeOnce.Do(func() {
|
||||
embeddedTree, embeddedTreeErr = LoadPolicyTreeFromFS(defaultsTreeFS, "defaults")
|
||||
|
|
@ -54,7 +80,7 @@ var (
|
|||
// tree into the single nested ZddcFile the cascade walker consumes, memoised.
|
||||
//
|
||||
// The cascade walker (EffectivePolicy) consults this as the bottom-most level
|
||||
// unless an on-disk .zddc up the chain sets `inherit: false`.
|
||||
// unless an on-disk .zddc / .zddc.zip up the chain sets `inherit: false`.
|
||||
func EmbeddedDefaults() (ZddcFile, error) {
|
||||
embeddedDefaultsOnce.Do(func() {
|
||||
tree, err := EmbeddedPolicyTree()
|
||||
|
|
|
|||
|
|
@ -1,319 +0,0 @@
|
|||
# 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 ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# Three roles ship empty (no members) — a fresh deployment grants
|
||||
# nothing until an operator populates them. Membership UNIONS across
|
||||
# the cascade; use `reset: true` at a subtree to start fresh.
|
||||
#
|
||||
# document_controller — owns the committed record and the party
|
||||
# registry. They:
|
||||
# - register parties: a party EXISTS iff ssr/<party>.yaml exists,
|
||||
# and the DC creates it (rwc at ssr/). This is the single
|
||||
# source of truth for party existence.
|
||||
# - file write-once into the WORM archive: read + create at
|
||||
# archive/<party>/received and issued via the worm: list (the
|
||||
# WORM mask strips w/d/a; create survives only for listed
|
||||
# principals). archive/ also grants rwc so the DC can create
|
||||
# party record dirs.
|
||||
# - rwcda across the live workspaces (incoming/working/staging/
|
||||
# reviewing), restated per-peer so a DC matched by the
|
||||
# project_team wildcard keeps full authority via within-level
|
||||
# union.
|
||||
# NOT a subtree-admin anywhere — no admins: entry. DCs cannot
|
||||
# bypass WORM (only worm-create); admin elevation is reserved for
|
||||
# the root admins: list (the human escape hatch for mis-filed
|
||||
# documents or recovery).
|
||||
#
|
||||
# project_team — everyone working on a project. Read across the
|
||||
# project, with a one-way ratchet through the live workspaces:
|
||||
# working/ cr create + read; auto_own gives the creator
|
||||
# rwcda inside the party folder they make
|
||||
# staging/ cr drop + read, no modify after the drop
|
||||
# reviewing/ cr create + read review iterations
|
||||
# incoming/ r counterparty's drop zone (observe)
|
||||
# archive/ r the committed record (received/issued), WORM
|
||||
# ssr/mdl/rsk r registry + registers (the DC maintains them)
|
||||
# Each handoff drops the role's modify rights for the previous
|
||||
# stage.
|
||||
#
|
||||
# observer — pure read-only across the project; no create anywhere.
|
||||
# Intended for auditors, regulators, and external read-only
|
||||
# viewers who must not contribute content.
|
||||
roles:
|
||||
document_controller:
|
||||
members: []
|
||||
project_team:
|
||||
members: []
|
||||
observer:
|
||||
members: []
|
||||
|
||||
# Universal tool baseline. archive (record browser), browse (file
|
||||
# tree, hosts the in-place markdown editor), and landing (project
|
||||
# picker) work everywhere. Each peer below adds its own tools
|
||||
# (transmittal in staging/, tables in mdl/rsk/ssr, etc.). available_tools
|
||||
# UNIONS across the cascade — 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:
|
||||
#
|
||||
# <dir>/ (trailing slash) → `dir_tool` — the directory view
|
||||
# (defaults to `browse`, the file-tree
|
||||
# navigator; you rarely set it).
|
||||
# <dir> (no slash) → `default_tool` — the specialized app
|
||||
# for this folder (archive, transmittal,
|
||||
# tables). If a folder declares no
|
||||
# default_tool, the no-slash form 302s
|
||||
# to the slash form.
|
||||
#
|
||||
# JSON listing requests are unaffected — they always get the raw
|
||||
# directory listing, so the browse SPA (and any client) can enumerate
|
||||
# entries regardless of dir_tool/default_tool. Both keys cascade
|
||||
# leaf→root.
|
||||
#
|
||||
# ── Canonical project structure (top-level party peers) ─────────────────────
|
||||
#
|
||||
# A project is a top-level directory. Under it sit a FLAT set of
|
||||
# physical, party-partitioned peers — there are no virtual aggregators:
|
||||
#
|
||||
# archive/<party>/{received,issued}/ the committed record. PURE
|
||||
# WORM (one rule on archive/, no
|
||||
# exceptions): write/delete
|
||||
# stripped for all; create only
|
||||
# for document_controller (the
|
||||
# worm: list); admins bypass.
|
||||
# Party record dirs appear on the
|
||||
# first filing.
|
||||
# incoming/<party>/ counterparty drop zone
|
||||
# reviewing/<party>/<tracking>/ we review their submission
|
||||
# working/<party>/ our drafts (edit-history on)
|
||||
# staging/<party>/<tracking>/ assemble transmittals
|
||||
# mdl/<party>/*.yaml master document list (tables)
|
||||
# rsk/<party>/*.yaml risk register (tables)
|
||||
# ssr/<party>.yaml submittal status register — AND
|
||||
# the AUTHORITATIVE PARTY REGISTRY
|
||||
#
|
||||
# Party registry: `ssr/<party>.yaml` existence is the SINGLE source of
|
||||
# truth for "party <party> exists". Creating it (rwc at ssr/, via the
|
||||
# SSR form) is how a party is born. Every OTHER peer carries
|
||||
# `party_source: ssr`, so you cannot create <peer>/<party>/… — archive
|
||||
# filing included — until the ssr row exists; the server 409s otherwise.
|
||||
# ssr/ itself has no party_source (it is the source).
|
||||
#
|
||||
# mdl/ and rsk/ AGGREGATE: the peer root renders ALL parties in one
|
||||
# table (a $party column derived from the real subdir), <peer>/<party>/
|
||||
# shows that party's rows. ssr/ aggregates naturally (one flat file per
|
||||
# party). $party is a real directory level, not a synthesized column.
|
||||
#
|
||||
# Mkdir at the project root is restricted to the peer names above plus
|
||||
# system (_/.-prefixed) names (see handler/fileapi.go). Nothing here
|
||||
# needs to exist on disk — the cascade resolves behaviour so a fresh
|
||||
# project lands on usable empty views at every well-known URL. Operators
|
||||
# override by mirroring this structure in an on-disk .zddc.
|
||||
|
||||
paths:
|
||||
# First segment under root is the project name; "*" matches any.
|
||||
"*":
|
||||
# Project-scoped baseline ACL. project_team and observer read across
|
||||
# the project; document_controller gets read + overwrite-existing.
|
||||
# None gets `c` here — create is granted only at the specific peers
|
||||
# below (archive/, ssr/, and the workspaces).
|
||||
acl:
|
||||
permissions:
|
||||
project_team: r
|
||||
observer: r
|
||||
document_controller: rw
|
||||
paths:
|
||||
# ── The committed record: pure WORM ─────────────────────────
|
||||
archive:
|
||||
default_tool: archive
|
||||
# A record can only be filed for a registered party.
|
||||
party_source: ssr
|
||||
# The ONE WORM rule. Cascades to <party>/{received,issued}:
|
||||
# write/delete stripped for everyone; create survives only for
|
||||
# document_controller; admins bypass (the escape hatch).
|
||||
worm: [document_controller]
|
||||
# rwc so a DC can create party record dirs (WORM masks w/d to
|
||||
# leave read + write-once-create).
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwc
|
||||
|
||||
# ── Authoritative party registry + submittal status register ─
|
||||
ssr:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
# NO party_source — ssr/ IS the source of party existence.
|
||||
# rwc: a DC registers a party by creating ssr/<party>.yaml and
|
||||
# maintains its status (overwrite). Delete (de-register) is left
|
||||
# to admins so a party with archived records is never orphaned.
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwc
|
||||
history: true
|
||||
records:
|
||||
"*.yaml":
|
||||
field_defaults:
|
||||
kind: SSR
|
||||
locked: [kind]
|
||||
|
||||
# ── Inbound workspace: counterparty drop zone ───────────────
|
||||
incoming:
|
||||
default_tool: classifier
|
||||
available_tools: [classifier]
|
||||
party_source: ssr
|
||||
# The other party's DC uploads here (a deployment grants them
|
||||
# cr, e.g. acl: { permissions: { "*@acme.com": cr } } at
|
||||
# incoming/Acme/.zddc); OUR DC QCs via classifier and moves to
|
||||
# archive/<party>/received. project_team has read only (observe).
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
paths:
|
||||
"*": # incoming/<party>
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
|
||||
# ── Inbound workspace: review of their submission ───────────
|
||||
reviewing:
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
party_source: ssr
|
||||
# The Plan-Review composite endpoint scaffolds a folder here per
|
||||
# submittal under review, with a .zddc carrying received_path
|
||||
# back to the canonical record in archive/<party>/received.
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
paths:
|
||||
"*": # reviewing/<party>
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
|
||||
# ── Outbound workspace: our drafts (edit-history on) ────────
|
||||
working:
|
||||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
party_source: ssr
|
||||
# Subtree-inheriting: every markdown save under working/ is
|
||||
# snapshotted to .zddc.d/history/<stem>/ with a server-stamped
|
||||
# audit line. Reads of recorded history never require this flag.
|
||||
history: true
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
paths:
|
||||
"*": # working/<party> — auto-owned by its creator
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
|
||||
# ── Outbound workspace: assemble transmittals ───────────────
|
||||
staging:
|
||||
default_tool: transmittal
|
||||
available_tools: [transmittal, classifier]
|
||||
party_source: ssr
|
||||
# project_team drops files (cr); after the drop the doc-control
|
||||
# workflow owns it. DC gets rwcda — `d` for the cut to issued/,
|
||||
# `a` so Plan Review can write staging/<tracking>/.zddc.
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
paths:
|
||||
"*": # staging/<party>
|
||||
auto_own: true
|
||||
drop_target: true
|
||||
|
||||
# ── Master document list (aggregates across parties) ────────
|
||||
mdl:
|
||||
default_tool: tables # peer root: all-parties table
|
||||
available_tools: [tables]
|
||||
party_source: ssr
|
||||
history: true
|
||||
# The deliverables register is collaboratively editable: the DC
|
||||
# manages it (rwcd) and project_team can create + edit rows (rwc,
|
||||
# no delete) — every change is captured by the history: audit above,
|
||||
# so broad write is safe. This project_team: rwc overrides the
|
||||
# project-level project_team: r (deepest matching level wins).
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
project_team: rwc
|
||||
# field_codes: constrain tracking-number components here (or
|
||||
# higher in the cascade). Three kinds — enum / pattern / free;
|
||||
# map-merge across levels. originator is folder-bound (below),
|
||||
# so it is not listed here. Example:
|
||||
# field_codes:
|
||||
# discipline: { kind: enum, codes: { EL: Electrical, ME: Mechanical } }
|
||||
# sequence: { kind: pattern, pattern: "[0-9]{4}" }
|
||||
paths:
|
||||
"*": # mdl/<party>: that party's rows, flat
|
||||
default_tool: tables
|
||||
# MDL records: each .yaml is an independent deliverable with
|
||||
# its own composed tracking number. originator is the party
|
||||
# folder (the record's own dir, distance 0 above
|
||||
# mdl/<party>/<file>.yaml) and renders read-only — the folder
|
||||
# is the single source of truth for the originator code.
|
||||
#
|
||||
# To add project-wide components (phase, area, …), override
|
||||
# filename_format here AND mdl/<party>/{form,table}.yaml.
|
||||
records:
|
||||
"*.yaml":
|
||||
folder_fields:
|
||||
originator: 0
|
||||
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}"
|
||||
|
||||
# ── Risk register (aggregates across parties) ───────────────
|
||||
rsk:
|
||||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
party_source: ssr
|
||||
history: true
|
||||
# Same as mdl/: DC manages (rwcd), project_team creates + edits rows
|
||||
# (rwc, no delete); the history: audit covers every change.
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
project_team: rwc
|
||||
paths:
|
||||
"*": # rsk/<party>
|
||||
default_tool: tables
|
||||
# RSK records: each .yaml is a row of a parent rsk-type
|
||||
# deliverable; the server auto-assigns -{row} within the
|
||||
# row-scope group on POST-create. originator is folder-bound
|
||||
# to the party folder, same as MDL.
|
||||
records:
|
||||
"*.yaml":
|
||||
folder_fields:
|
||||
originator: 0
|
||||
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}-{row}"
|
||||
field_defaults:
|
||||
type: RSK
|
||||
locked: [type]
|
||||
row_field: row
|
||||
row_scope_fields: [originator, project, discipline, type, sequence, suffix]
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
package zddc
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEmbeddedDefaultsParse — the shipped defaults.zddc.yaml must
|
||||
// parse cleanly into a ZddcFile. Regression guard against accidental
|
||||
// YAML syntax errors in the source-of-truth file.
|
||||
// TestEmbeddedDefaultsParse — the embedded per-depth default tree must assemble
|
||||
// + parse cleanly into a ZddcFile. Regression guard against a broken member.
|
||||
func TestEmbeddedDefaultsParse(t *testing.T) {
|
||||
zf, err := EmbeddedDefaults()
|
||||
if err != nil {
|
||||
|
|
@ -20,16 +21,35 @@ func TestEmbeddedDefaultsParse(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestEmbeddedDefaultsBytesDumpable — the bytes used by the show-
|
||||
// defaults CLI must be non-empty and start with a comment so an
|
||||
// operator pasting them into a real file sees the header.
|
||||
func TestEmbeddedDefaultsBytesDumpable(t *testing.T) {
|
||||
got := EmbeddedDefaultsBytes()
|
||||
if len(got) == 0 {
|
||||
t.Fatal("EmbeddedDefaultsBytes returned empty slice")
|
||||
// TestEmbeddedDefaultsZipDumpable — the .zddc.zip emitted by show-defaults must
|
||||
// be a valid archive carrying the per-depth policy members with "*" wildcard
|
||||
// segments (no leftover _any_ placeholder).
|
||||
func TestEmbeddedDefaultsZipDumpable(t *testing.T) {
|
||||
b, err := EmbeddedDefaultsZip()
|
||||
if err != nil {
|
||||
t.Fatalf("EmbeddedDefaultsZip: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimLeft(string(got), " \t"), "#") {
|
||||
t.Errorf("expected leading comment, got: %q", string(got[:60]))
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
t.Fatalf("not a valid zip: %v", err)
|
||||
}
|
||||
var hasRoot, hasWildcard bool
|
||||
for _, f := range zr.File {
|
||||
if strings.Contains(f.Name, AnyPlaceholder) {
|
||||
t.Errorf("member %q still has the _any_ placeholder; want * wildcard", f.Name)
|
||||
}
|
||||
switch f.Name {
|
||||
case ".zddc":
|
||||
hasRoot = true
|
||||
case "*/working/.zddc":
|
||||
hasWildcard = true
|
||||
}
|
||||
}
|
||||
if !hasRoot {
|
||||
t.Error("zip missing root .zddc member")
|
||||
}
|
||||
if !hasWildcard {
|
||||
t.Error(`zip missing "*/working/.zddc" member`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
|||
|
||||
// Determine if this newly-created ancestor is an auto-own
|
||||
// position and whether it should be fenced (inherit: false).
|
||||
// Resolved via the .zddc cascade — defaults.zddc.yaml
|
||||
// Resolved via the .zddc cascade — internal/zddc/defaults/
|
||||
// carries the canonical "working/staging auto-own + per-user
|
||||
// homes fenced + incoming auto-own" convention, and any
|
||||
// on-disk .zddc can override per-directory.
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ func ChildrenDeclaredAt(fsRoot, dirPath string) []string {
|
|||
// (received, issued) — or "" if the path is not at a canonical slot.
|
||||
//
|
||||
// Detection is structural against the flat-peer layout declared in
|
||||
// defaults.zddc.yaml:
|
||||
// internal/zddc/defaults/:
|
||||
//
|
||||
// - second-level <project>/<peer> for any top-level peer.
|
||||
// - third-level <project>/<peer>/<party> reports its peer (slot) for
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
)
|
||||
|
||||
// TestDefaultToolAt_FromEmbeddedConvention — the canonical default-tool
|
||||
// rules in defaults.zddc.yaml resolve correctly for the flat top-level
|
||||
// rules in internal/zddc/defaults/ resolve correctly for the flat top-level
|
||||
// peers (and their per-party subdirs) without any on-disk .zddc.
|
||||
func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
||||
resetCache()
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ func lookupRoleMembers(chain PolicyChain, levelIdx int, roleName string) ([]stri
|
|||
}
|
||||
// The embedded defaults sit below chain.Levels[0] in the cascade.
|
||||
// Fold its role definitions in as the baseline (so a role declared
|
||||
// only in defaults.zddc.yaml is "defined", and a deployment's
|
||||
// only in internal/zddc/defaults/ is "defined", and a deployment's
|
||||
// on-disk redefinition unions on top). Skipped above only if a
|
||||
// reset:true level already returned.
|
||||
if role, ok := chain.Embedded.Roles[roleName]; ok {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import "strings"
|
|||
//
|
||||
// Note the layering: the names are hard-coded here, but per-peer
|
||||
// BEHAVIOR (default_tool, worm, party_source, history, auto_own, …)
|
||||
// stays cascade-driven in defaults.zddc.yaml + on-disk .zddc. This file
|
||||
// stays cascade-driven in internal/zddc/defaults/ + on-disk .zddc. This file
|
||||
// is identity/shape only.
|
||||
var (
|
||||
// projectPeers: the flat set of physical directories permitted
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
)
|
||||
|
||||
// The .zddc cascade is the authority for canonical-folder behaviour;
|
||||
// see defaults.zddc.yaml for the conventions and lookups.go for the
|
||||
// see internal/zddc/defaults/ for the conventions and lookups.go for the
|
||||
// helpers consumers call (DefaultToolAt, AutoOwnAt, VirtualAt,
|
||||
// IsDeclaredPath, ChildrenDeclaredAt, AvailableToolsAt).
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
// TestWormMaskStripsWDA retired in the cascade-config migration. The
|
||||
// `auto_own:` and `worm:` .zddc keys carry these conventions now —
|
||||
// see lookups_test.go (AutoOwnAt) and worm_test.go (WormZoneGrant);
|
||||
// the canonical defaults live in defaults.zddc.yaml.
|
||||
// the canonical defaults live in internal/zddc/defaults/.
|
||||
|
||||
func TestResolveCanonicalCaseFold(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
|
@ -56,6 +56,6 @@ func TestResolveCanonicalMissingParent(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestCanonicalLists and TestIsProjectRootFolder retired in Phase 3 —
|
||||
// the canonical convention is now expressed in defaults.zddc.yaml and
|
||||
// the canonical convention is now expressed in internal/zddc/defaults/ and
|
||||
// asserted by lookups_test.go (TestDefaultToolAt_FromEmbeddedConvention,
|
||||
// TestIsDeclaredPath_FromEmbeddedConvention, etc.).
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
|
|||
resetCache()
|
||||
root := t.TempDir()
|
||||
// DC authority comes PURELY from the cascade peer grants in
|
||||
// defaults.zddc.yaml — no auto-own / admins: list. DCs are typically
|
||||
// internal/zddc/defaults/ — no auto-own / admins: list. DCs are typically
|
||||
// in project_team too (the *@example.com wildcard); the defaults
|
||||
// restate document_controller at each peer so the within-level union
|
||||
// gives the DC the higher grant.
|
||||
|
|
@ -221,7 +221,7 @@ func TestStandardRoles_ProjectTeamInFlightRatchet(t *testing.T) {
|
|||
// TestStandardRoles_DocControllerStagingDelete — DC needs `d` at
|
||||
// staging/ to perform the staging-to-issued transfer (cut, not copy);
|
||||
// the explicit document_controller: rwcd grant supplies it. Mirrors
|
||||
// the incoming/ pattern (line 286-288 of defaults.zddc.yaml) where
|
||||
// the incoming/ pattern (line 286-288 of internal/zddc/defaults/) where
|
||||
// the QC + transfer-out workflow needed the same.
|
||||
func TestStandardRoles_DocControllerStagingDelete(t *testing.T) {
|
||||
resetCache()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ package zddc
|
|||
//
|
||||
// Replaces the hardcoded IsWormPath / WormFolderLevelIndex / WormMask
|
||||
// machinery (which keyed off the literal folder names "received" and
|
||||
// "issued"). The convention now lives in defaults.zddc.yaml — those
|
||||
// "issued"). The convention now lives in internal/zddc/defaults/ — those
|
||||
// two folders carry `worm: {}` — and any operator can mark another
|
||||
// directory WORM by adding `worm:` to its .zddc.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
// TestWormZoneGrant_EmbeddedConvention — defaults.zddc.yaml declares
|
||||
// TestWormZoneGrant_EmbeddedConvention — internal/zddc/defaults/ declares
|
||||
// `worm: [document_controller]` on archive/, so the ENTIRE archive
|
||||
// subtree is a WORM zone (inWorm=true). With no role members in this
|
||||
// bare fixture the grant for an arbitrary principal is 0. The top-level
|
||||
|
|
|
|||
|
|
@ -119,24 +119,6 @@ func TestPolicyTreeAlong(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Phase-4 gate: the embedded per-depth tree assembles to EXACTLY the legacy
|
||||
// defaults.zddc.yaml ZddcFile, so pointing EmbeddedDefaults at the tree is a
|
||||
// behavioral no-op. (The Layer-2 matrix is the decision-level confirmation.)
|
||||
func TestEmbeddedTreeMatchesYAML(t *testing.T) {
|
||||
tree, err := EmbeddedPolicyTree()
|
||||
if err != nil {
|
||||
t.Fatalf("embedded tree: %v", err)
|
||||
}
|
||||
assembled := tree.Assemble()
|
||||
legacy, err := parseBytes(defaultsBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse legacy yaml: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(assembled, legacy) {
|
||||
t.Errorf("assembled per-depth tree != legacy defaults.zddc.yaml\n assembled=%+v\n legacy=%+v", assembled, legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(t PolicyTree) []string {
|
||||
out := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
|
|
|
|||
Loading…
Reference in a new issue