ZDDC/zddc/internal/zddc/walker.go
ZDDC ba98b87b2a feat(roles): in-flight ratchet + auto_own_roles, drop DC subtree-admin
Two related schema/defaults changes that together replace the
admins:[document_controller] subtree-admin status with a cleaner
role-grant-via-auto-own model, and lock down the one-way handoff
through the in-flight lifecycle slots.

## New: auto_own_roles

ZddcFile.AutoOwnRoles []string is a new field on the parent's .zddc
declaring "when this directory's auto_own fires, also grant these
roles rwcda alongside the creator email". The writer
(WriteAutoOwnZddc + WriteAutoOwnZddcFenced) now takes a roles slice
and writes both the creator email AND each named role as rwcda in
the new .zddc. mergeOverlay treats AutoOwnRoles like other path-tree
contributions (leaf-wins).

The defaults' archive/<party>/ entry now sets
`auto_own_roles: [document_controller]` and drops the
`admins: [document_controller]` line:

  - When any DC mkdir's archive/<party>/, the auto-own .zddc grants
    both their email and the role rwcda. Peer DCs share full
    authority at every party without any DC needing subtree-admin
    status.
  - DCs are no longer subtree-admins anywhere. They can't bypass
    WORM (only worm-create via the worm: list) and can't reach
    inside fenced working homes. Admin elevation is reserved for
    the root admins: list.
  - Plan Review's ActionAdmin pre-flight passes for any DC via the
    role grant cascading into reviewing/ and staging/.

## In-flight ratchet (working → staging → issued)

Per-role grants at the lifecycle slots formalise a one-way handoff:

  working/   project_team: cr (create their own folders;
                              auto_own_fenced gives rwcda inside)
  staging/   project_team: cr (drop files, no modify after — the
                              "commit" step; DC takes over)
             document_controller: rwcd (transfer-to-issued needs `d`)
  reviewing/ project_team: cr (create iteration folders; auto_own
                              unfenced grants rwcda inside)
  received/  worm cr (file write-once)
  issued/    worm cr

Each handoff drops the previous role's modify rights for the slot
they pushed from. Comments in defaults.zddc.yaml document the
pattern + the "project_team drops files at staging root, never
mkdirs" convention.

## Tests

TestStandardRoles_DocControllerScopedCreate rewritten — flips
from IsSubtreeAdmin assertions to verifying:
  - rwcda at <party>/ via the auto-own .zddc (creator + role)
  - rwcda cascading to working/reviewing/ (no slot override)
  - rwcd at incoming/staging/ via explicit grants
  - cr at received/issued via WORM mask
  - IsSubtreeAdmin = false everywhere
  - DC blocked from alice's fenced working/<email>/ home

New TestStandardRoles_DocControllerMultiDC — a second DC in the
role gets the same rwcda at any party a peer created, via the role
grant in auto_own_roles.

New TestStandardRoles_ProjectTeamInFlightRatchet locks the ratchet:
project_team gets cr at working/staging/reviewing, r at incoming/
received/issued.

New TestStandardRoles_DocControllerStagingDelete confirms DC has
`d` at staging/ for the transfer-to-issued workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:51:07 -05:00

287 lines
7.9 KiB
Go

package zddc
import (
"strings"
)
// matchGlob looks up a path segment in a paths: map. Literal
// (case-insensitive) match first; falls back to a "*" segment-
// wildcard key if present. Returns nil when neither hits.
//
// Phase 2: single-segment globs only. Multi-segment keys (a/b) are
// rejected at the schema level and never reach this lookup.
func matchGlob(m map[string]ZddcFile, seg string) *ZddcFile {
if m == nil {
return nil
}
// Fast path: exact key match (case-sensitive — operator-controlled).
if v, ok := m[seg]; ok {
return &v
}
// Case-insensitive literal match for canonical-folder ergonomics
// (operator writes `archive:`; on-disk dir may be `Archive`).
lower := strings.ToLower(seg)
for k, v := range m {
if k == "*" {
continue
}
if strings.ToLower(k) == lower {
return &v
}
}
// Wildcard fallback.
if v, ok := m["*"]; ok {
return &v
}
return nil
}
// mergeOverlay composes two ZddcFile values into one. `top` overrides
// `base` per-field. Maps merge key-by-key (top wins on key clash);
// scalar fields take top's value when non-zero (allowing base to fill
// in unset fields).
//
// The intended use is a stack of contributions from lowest to highest
// specificity, applied in order:
//
// merged = empty
// for c in ancestor_virtual_contributions { // lowest specificity first
// merged = mergeOverlay(merged, c)
// }
// merged = mergeOverlay(merged, on_disk_at_this_level)
//
// Each successive overlay overrides what came before for the same key.
func mergeOverlay(base, top ZddcFile) ZddcFile {
out := base
if top.Title != "" {
out.Title = top.Title
}
if top.AppsPubKey != "" {
out.AppsPubKey = top.AppsPubKey
}
if top.CreatedBy != "" {
out.CreatedBy = top.CreatedBy
}
if top.Inherit != nil {
out.Inherit = top.Inherit
}
if top.DefaultTool != "" {
out.DefaultTool = top.DefaultTool
}
if top.DirTool != "" {
out.DirTool = top.DirTool
}
if top.AutoOwn != nil {
out.AutoOwn = top.AutoOwn
}
if top.AutoOwnFenced != nil {
out.AutoOwnFenced = top.AutoOwnFenced
}
// AutoOwnRoles: presence (non-nil) overrides; a deeper level
// declaring an empty list replaces (and explicitly suppresses)
// the ancestor's role list. This matches the leaf-wins semantic
// for the other path-tree contribution lists.
if top.AutoOwnRoles != nil {
out.AutoOwnRoles = top.AutoOwnRoles
}
if top.DropTarget != nil {
out.DropTarget = top.DropTarget
}
// Worm: presence (non-nil, even empty) marks the WORM zone.
// Concat-dedupe across levels (a deeper .zddc adds controllers);
// preserve a non-nil empty slice so `worm: []` survives the
// overlay.
if top.Worm != nil {
out.Worm = mergeStringSlicePreserveEmpty(out.Worm, top.Worm)
}
if top.Virtual != nil {
out.Virtual = top.Virtual
}
if top.ReceivedPath != "" {
out.ReceivedPath = top.ReceivedPath
}
if top.PlannedReviewDate != "" {
out.PlannedReviewDate = top.PlannedReviewDate
}
if top.PlannedResponseDate != "" {
out.PlannedResponseDate = top.PlannedResponseDate
}
out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools)
out.Admins = mergeStringSlice(out.Admins, top.Admins)
if top.ACL.Inherit != nil {
out.ACL.Inherit = top.ACL.Inherit
}
out.ACL.Permissions = mergeStringMap(out.ACL.Permissions, top.ACL.Permissions)
out.Apps = mergeStringMap(out.Apps, top.Apps)
out.Tables = mergeStringMap(out.Tables, top.Tables)
out.Display = mergeStringMap(out.Display, top.Display)
// Convert: per-key latest-wins. Pointer-to-struct so we can tell
// "absent" from "explicitly empty" — the latter is rare but valid
// (an operator who wants to suppress a deployment-default value).
// Empty top values do NOT clear the ancestor value; operators must
// set an explicit non-empty string to override.
if top.Convert != nil {
if out.Convert == nil {
out.Convert = &ConvertMetadata{}
}
if top.Convert.Client != "" {
out.Convert.Client = top.Convert.Client
}
if top.Convert.Project != "" {
out.Convert.Project = top.Convert.Project
}
if top.Convert.Contractor != "" {
out.Convert.Contractor = top.Convert.Contractor
}
if top.Convert.ProjectNumber != "" {
out.Convert.ProjectNumber = top.Convert.ProjectNumber
}
}
// Roles: per-name merge (top wins on name clash). This combines
// the on-disk .zddc at a level with any virtual contributions
// from ancestor paths: at the same level. Cross-LEVEL role
// membership union (and the reset flag) is handled at lookup
// time by lookupRoleMembers, not here.
if len(top.Roles) > 0 {
if out.Roles == nil {
out.Roles = make(map[string]Role, len(top.Roles))
} else {
merged := make(map[string]Role, len(out.Roles)+len(top.Roles))
for k, v := range out.Roles {
merged[k] = v
}
out.Roles = merged
}
for k, v := range top.Roles {
out.Roles[k] = v
}
}
// FieldCodes: map-merge keyed by field name. Top wins on key
// clash — a sub-tree can narrow or replace a single code's
// vocabulary without dropping unrelated codes. Mirror of Apps.
if len(top.FieldCodes) > 0 {
if out.FieldCodes == nil {
out.FieldCodes = make(map[string]FieldCode, len(top.FieldCodes))
} else {
merged := make(map[string]FieldCode, len(out.FieldCodes)+len(top.FieldCodes))
for k, v := range out.FieldCodes {
merged[k] = v
}
out.FieldCodes = merged
}
for k, v := range top.FieldCodes {
out.FieldCodes[k] = v
}
}
// Records: map-merge keyed by filename pattern. Each entry's
// inner fields merge via mergeRecordRule (scalars overwrite,
// FieldDefaults map-merge, Locked concat-dedupe). Two different
// patterns at different cascade levels coexist as independent
// entries; identical patterns merge their contents.
if len(top.Records) > 0 {
if out.Records == nil {
out.Records = make(map[string]RecordRule, len(top.Records))
} else {
merged := make(map[string]RecordRule, len(out.Records)+len(top.Records))
for k, v := range out.Records {
merged[k] = v
}
out.Records = merged
}
for k, v := range top.Records {
out.Records[k] = mergeRecordRule(out.Records[k], v)
}
}
// Paths: top entirely replaces base if set. Recursive descent of
// the walker is what threads ancestor Paths through to the right
// level — merging Paths maps themselves at this layer would
// double-apply.
if len(top.Paths) > 0 {
out.Paths = top.Paths
}
return out
}
func mergeStringMap(base, top map[string]string) map[string]string {
if len(top) == 0 {
return base
}
if len(base) == 0 {
out := make(map[string]string, len(top))
for k, v := range top {
out[k] = v
}
return out
}
out := make(map[string]string, len(base)+len(top))
for k, v := range base {
out[k] = v
}
for k, v := range top {
out[k] = v
}
return out
}
// mergeStringSlicePreserveEmpty is mergeStringSlice but always returns
// a non-nil result when top is non-nil — so an empty `worm: []` in a
// .zddc still marks the WORM zone after the overlay. Caller is
// expected to only invoke this when top != nil.
func mergeStringSlicePreserveEmpty(base, top []string) []string {
seen := make(map[string]struct{}, len(base)+len(top))
out := make([]string, 0, len(base)+len(top))
for _, v := range base {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
for _, v := range top {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
func mergeStringSlice(base, top []string) []string {
if len(top) == 0 {
return base
}
if len(base) == 0 {
out := make([]string, len(top))
copy(out, top)
return out
}
// Concatenate with dedupe (preserve order: base first, then
// top entries that weren't already in base).
seen := make(map[string]struct{}, len(base)+len(top))
out := make([]string, 0, len(base)+len(top))
for _, v := range base {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
for _, v := range top {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}