From 1e0e403f1e7ee32fc679f6e5cb2a5be07f3d913e Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 5 Jun 2026 11:35:21 -0500 Subject: [PATCH] feat(zddc): retire defaults.zddc.yaml; .zddc.zip is the policy carrier (phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /.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) --- zddc/GRAMMAR.md | 28 +- zddc/cmd/zddc-server/main.go | 25 +- zddc/internal/apps/availability.go | 4 +- zddc/internal/fs/tree.go | 2 +- zddc/internal/handler/defaults_matrix_test.go | 2 +- zddc/internal/handler/fileapi.go | 2 +- zddc/internal/handler/ssrhandler.go | 2 +- zddc/internal/handler/zddcfile.go | 6 +- zddc/internal/handler/zddcfile_test.go | 2 +- zddc/internal/policy/policy.go | 2 +- zddc/internal/zddc/defaults.go | 64 ++-- zddc/internal/zddc/defaults.zddc.yaml | 319 ------------------ zddc/internal/zddc/defaults_test.go | 44 ++- zddc/internal/zddc/ensure.go | 2 +- zddc/internal/zddc/lookups.go | 2 +- zddc/internal/zddc/lookups_test.go | 2 +- zddc/internal/zddc/roles.go | 2 +- zddc/internal/zddc/slots.go | 2 +- zddc/internal/zddc/special.go | 2 +- zddc/internal/zddc/special_test.go | 4 +- zddc/internal/zddc/standardroles_test.go | 4 +- zddc/internal/zddc/worm.go | 2 +- zddc/internal/zddc/worm_test.go | 2 +- zddc/internal/zddc/zippolicy_test.go | 18 - 24 files changed, 134 insertions(+), 410 deletions(-) delete mode 100644 zddc/internal/zddc/defaults.zddc.yaml diff --git a/zddc/GRAMMAR.md b/zddc/GRAMMAR.md index 27dfa10..e3833bd 100644 --- a/zddc/GRAMMAR.md +++ b/zddc/GRAMMAR.md @@ -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`. --- diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index f391604..56f4b22 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -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 diff --git a/zddc/internal/apps/availability.go b/zddc/internal/apps/availability.go index e2c7f37..85866a7 100644 --- a/zddc/internal/apps/availability.go +++ b/zddc/internal/apps/availability.go @@ -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 .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 diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index eeebd56..747e496 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -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//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 diff --git a/zddc/internal/handler/defaults_matrix_test.go b/zddc/internal/handler/defaults_matrix_test.go index 6cd56ee..155612f 100644 --- a/zddc/internal/handler/defaults_matrix_test.go +++ b/zddc/internal/handler/defaults_matrix_test.go @@ -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 diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index 3e47754..f46843c 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -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 diff --git a/zddc/internal/handler/ssrhandler.go b/zddc/internal/handler/ssrhandler.go index 615ed36..e6f736e 100644 --- a/zddc/internal/handler/ssrhandler.go +++ b/zddc/internal/handler/ssrhandler.go @@ -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 diff --git a/zddc/internal/handler/zddcfile.go b/zddc/internal/handler/zddcfile.go index eba1009..07a4f82 100644 --- a/zddc/internal/handler/zddcfile.go +++ b/zddc/internal/handler/zddcfile.go @@ -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") diff --git a/zddc/internal/handler/zddcfile_test.go b/zddc/internal/handler/zddcfile_test.go index ced8ca9..2130c55 100644 --- a/zddc/internal/handler/zddcfile_test.go +++ b/zddc/internal/handler/zddcfile_test.go @@ -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() diff --git a/zddc/internal/policy/policy.go b/zddc/internal/policy/policy.go index 01937d1..1ea2a0f 100644 --- a/zddc/internal/policy/policy.go +++ b/zddc/internal/policy/policy.go @@ -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//received and issued carry + // internal/zddc/defaults/ — archive//received and issued carry // `worm: {}`) is write-locked. Inside it, the effective verbs // for a non-admin principal are: // diff --git a/zddc/internal/zddc/defaults.go b/zddc/internal/zddc/defaults.go index d06ff69..5aa7c74 100644 --- a/zddc/internal/zddc/defaults.go +++ b/zddc/internal/zddc/defaults.go @@ -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.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() diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml deleted file mode 100644 index 2620d94..0000000 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ /dev/null @@ -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/.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//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: -# -# / (trailing slash) → `dir_tool` — the directory view -# (defaults to `browse`, the file-tree -# navigator; you rarely set it). -# (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//{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// counterparty drop zone -# reviewing/// we review their submission -# working// our drafts (edit-history on) -# staging/// assemble transmittals -# mdl//*.yaml master document list (tables) -# rsk//*.yaml risk register (tables) -# ssr/.yaml submittal status register — AND -# the AUTHORITATIVE PARTY REGISTRY -# -# Party registry: `ssr/.yaml` existence is the SINGLE source of -# truth for "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 //… — 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), // -# 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 /{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/.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//received. project_team has read only (observe). - acl: - permissions: - document_controller: rwcd - paths: - "*": # incoming/ - 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//received. - acl: - permissions: - project_team: cr - document_controller: rwcda - paths: - "*": # reviewing/ - 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// 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/ — 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//.zddc. - acl: - permissions: - project_team: cr - document_controller: rwcda - paths: - "*": # staging/ - 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/: 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//.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//{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/ - 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] diff --git a/zddc/internal/zddc/defaults_test.go b/zddc/internal/zddc/defaults_test.go index 4ab97c9..96a8ca5 100644 --- a/zddc/internal/zddc/defaults_test.go +++ b/zddc/internal/zddc/defaults_test.go @@ -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`) } } diff --git a/zddc/internal/zddc/ensure.go b/zddc/internal/zddc/ensure.go index ed2dc91..168a34e 100644 --- a/zddc/internal/zddc/ensure.go +++ b/zddc/internal/zddc/ensure.go @@ -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. diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index eed0b75..0d7dc39 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -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 / for any top-level peer. // - third-level // reports its peer (slot) for diff --git a/zddc/internal/zddc/lookups_test.go b/zddc/internal/zddc/lookups_test.go index 7bdafee..63dc14b 100644 --- a/zddc/internal/zddc/lookups_test.go +++ b/zddc/internal/zddc/lookups_test.go @@ -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() diff --git a/zddc/internal/zddc/roles.go b/zddc/internal/zddc/roles.go index cb3758d..dbcacdb 100644 --- a/zddc/internal/zddc/roles.go +++ b/zddc/internal/zddc/roles.go @@ -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 { diff --git a/zddc/internal/zddc/slots.go b/zddc/internal/zddc/slots.go index 8dcf56b..e41d418 100644 --- a/zddc/internal/zddc/slots.go +++ b/zddc/internal/zddc/slots.go @@ -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 diff --git a/zddc/internal/zddc/special.go b/zddc/internal/zddc/special.go index a5fe197..651d070 100644 --- a/zddc/internal/zddc/special.go +++ b/zddc/internal/zddc/special.go @@ -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). diff --git a/zddc/internal/zddc/special_test.go b/zddc/internal/zddc/special_test.go index 1aab83d..670e4aa 100644 --- a/zddc/internal/zddc/special_test.go +++ b/zddc/internal/zddc/special_test.go @@ -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.). diff --git a/zddc/internal/zddc/standardroles_test.go b/zddc/internal/zddc/standardroles_test.go index b880dee..149ae1c 100644 --- a/zddc/internal/zddc/standardroles_test.go +++ b/zddc/internal/zddc/standardroles_test.go @@ -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() diff --git a/zddc/internal/zddc/worm.go b/zddc/internal/zddc/worm.go index 6e3b4b9..630f9e9 100644 --- a/zddc/internal/zddc/worm.go +++ b/zddc/internal/zddc/worm.go @@ -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. diff --git a/zddc/internal/zddc/worm_test.go b/zddc/internal/zddc/worm_test.go index c658a81..95dea21 100644 --- a/zddc/internal/zddc/worm_test.go +++ b/zddc/internal/zddc/worm_test.go @@ -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 diff --git a/zddc/internal/zddc/zippolicy_test.go b/zddc/internal/zddc/zippolicy_test.go index a98e002..cc6a181 100644 --- a/zddc/internal/zddc/zippolicy_test.go +++ b/zddc/internal/zddc/zippolicy_test.go @@ -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 {