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>
Single audit pass that removes pre-release back-compat, consolidates the
admin-policy decider, and fixes the .zddc write path.
Field removal — acl.allow / acl.deny:
- Drop ACLRules.Allow / Deny struct fields and mergeLegacyACL().
- Remove walker / lookups / validate / decider branches that read them.
- Migrate every test fixture (YAML strings and ACLRules struct literals)
to acl.permissions: { principal → verb-set }.
- Rewrite both bundled Rego policies (access.rego, access_federal.rego)
to traverse level.acl.permissions; rewrite parity-test helpers.
- Update create-project form (profile page) to collect permissions
instead of allow/deny lists.
Admin decider consolidation:
- Delete zddc.CanEditZddc — strict-ancestor rule retired. Subtree admins
own their own .zddc; the policy decider's IsActiveAdmin short-circuit
is the single bypass site.
- Migrate tablehandler.ServeTable to AllowActionFromChainP — closes the
same Forbidden bug already fixed for /browse.html.
- Drop AccessView.EditableParentChoices and treeEntry.CanEdit (always
true after the retirement). Profile page renders AdminSubtrees
directly for both lists.
- Drop the excludeLeaf parameter from AdminLevelInChain /
IsAdminForChain — no production caller passed true.
Dead code removed:
- policy.AllowWriteFromChain (zero production callers, zero tests).
- zddc.AllowedWithChain (zero production callers; tests deleted).
ModeStrict retirement — federal posture is OPA-only:
- Delete cascade_mode.go / cascade_mode_test.go and the ModeStrict
branches in cascade.go and acl.go.
- Drop --cascade-mode flag, CascadeMode config field, and the
InternalDecider.Mode field.
- Drop the mode parameter from every cascade helper:
GrantedVerbsAtLevel, AllowedAction, EffectiveVerbs,
EffectiveVerbsRange, RoleMembers, MatchesPrincipal,
MatchingPrincipals, WormZoneGrant, PolicyChain.VisibleStart.
- Strip cascade_mode from /.profile/config and
/.profile/effective-policy responses.
- Refresh README / ARCHITECTURE.md to describe federal posture as
"deploy OPA with access_federal.rego" (NIST AC-6); the bundled Rego
is the parent-deny-is-absolute variant. The in-process Go evaluator
implements only the commercial cascade.
Legacy redirects + .admin.css fallback:
- Drop /<dir>/.zddc.html → ?file=.zddc redirect and its test.
- Drop ?zip=1 retired comment + legacy test (handled by the
.zip virtual-URL path; covered by TestServeSubtreeZip).
- Drop .admin.css fallback in profile_assets.go — only .profile.css now.
- Refresh stale "retired" / "back-compat" / "legacy" comment markers.
.zddc write path fix:
- Dispatcher: route only GET/HEAD on .zddc URLs to ServeZddcFile; carve
.zddc out of the dot-prefix guard so PUT/DELETE/POST reach
ServeFileAPI. Before this, .zddc writes 405'd at ServeZddcFile and
the YAML editor's save flow had no live path.
- ServeFileAPI.resolveTargetPath: same .zddc-leaf carve-out so the file
API accepts the path; intermediate dot dirs (.zddc.d/) stay reserved.
- Listing: compute Writable per-file with ActionAdmin for .zddc
(matches the file API's gate) instead of ActionWrite for everything.
- Virtual .zddc placeholder: compute Writable via the same
parentActiveAdmin || ActionAdmin path. Was always false before.
- browse YAML editor canSave: exempt virtual .zddc — the synthetic
body is designed to materialize on PUT.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The file API's mkdir post-hook still seeded auto-own .zddc files via the
hardcoded IsAutoOwnPath path-segment predicate, while
EnsureCanonicalAncestors had already moved to the cascade's auto_own:
flag. Point the hook at AutoOwnAt / AutoOwnFencedAt so both paths agree
and an operator's .zddc reshaping actually takes effect — fenced when
the new directory's own cascade level declares auto_own_fenced (per-user
working homes), unfenced otherwise.
Retires IsAutoOwnPath and WormMask (the latter already superseded by
WormZoneGrant's & VerbsRC) plus their tests, and the now-unused
path/filepath import in special.go.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WORM (write-once-read-many) is no longer a special folder type keyed
off the literal names "received"/"issued". It's a cascade key —
`worm:` on any directory's .zddc — with the ACL-shaped semantics the
user described.
Schema:
worm:
"doc-control@example.com": cr # email-glob or @role:name → verbs ⊆ {r, c}
# an empty map ({}) is a WORM zone with no create-capable principals
Effect inside a WORM zone (any cascade level declares worm:), applied
AFTER the normal cascade ACL and BEFORE the admin escape hatch:
- w / d / a stripped for everyone
- c survives only via the worm: map
- r survives via the normal ACL OR the worm: map (so a document
controller who isn't in the project ACL still gets read+create)
- worm: grants UNION across the cascade — deeper .zddc can name
more controllers
- admins (root / subtree) bypass entirely — handler does the
IsAdmin check before the policy evaluator
defaults.zddc.yaml: archive/<party>/received and archive/<party>/issued
carry `worm: {}` (WORM zone, no controllers — the deployment names
its document controller by adding a deeper .zddc with
`worm: {<principal>: cr}`). The canonical convention is unchanged;
the difference is an operator can now mark any directory WORM, or
rename received/issued, without a code change.
Removed (hardcoded path predicates, superseded by the cascade walk):
zddc.IsWormPath
zddc.WormFolderLevelIndex
zddc.splitPathSegments (only IsWormPath used it)
Kept: zddc.WormMask (generic verb-set primitive), zddc.VerbsRC.
New:
zddc.WormZoneGrant(chain, email, mode) → (verbs, inWormZone)
Walks the chain for worm: declarations; unions the principal's
grants masked to {r, c}.
policy.InternalDecider.Allow: WORM block rewritten to consult
WormZoneGrant instead of IsWormPath/WormFolderLevelIndex.
ValidateFile: worm: keys validated as email-glob (or @role:name);
values validated as verb strings ⊆ {r, c}.
Tests:
- new worm_test.go covers the embedded convention, operator-granted
controller, w/d masking, cross-cascade union.
- special_test.go's TestIsWormPath / TestWormFolderLevelIndex
retired; TestWormMaskStripsWDA kept.
- fileapi_test.go's WORM tests updated: the doc-controller grant is
now `worm: { _doc_controller: cr }` at issued/.zddc, not
`acl.permissions: { _doc_controller: cr }`.
- federal-parity and admin-bypass tests unchanged — the WORM mask
still strips w/d/a and admins still bypass.
All Go tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 retired these symbols by migrating their consumers to the
.zddc cascade lookups. Removing them now that nothing references
them:
- var zddc.ProjectRootFolders
- var zddc.PartyFolders
- var zddc.AutoOwnCanonicalNames
- var zddc.VirtualOnlyCanonicalNames
- func zddc.IsProjectRootFolder
- func zddc.IsArchivePartyFolder
- func zddc.IsArchivePartyMdlDir
- func handler.isArchivePartyDir
The canonical convention is expressed in defaults.zddc.yaml and
consulted via lookups.go's DefaultToolAt / AutoOwnAt / VirtualAt /
IsDeclaredPath / ChildrenDeclaredAt / AvailableToolsAt /
IsToolAvailableAt. Operators override per-directory via on-disk
.zddc files; the embedded layer is the documented baseline.
Test removals:
- TestCanonicalLists (lists no longer exist)
- TestIsProjectRootFolder (function no longer exists)
Equivalent coverage lives in lookups_test.go's
TestDefaultToolAt_FromEmbeddedConvention,
TestIsDeclaredPath_FromEmbeddedConvention, etc. — which assert the
convention via the cascade's actual lookup path rather than the
predicates' return values.
handler.isAtArchivePartyMdlDir is RETAINED — it's still actively
consumed by RecognizeTableRequest's default-MDL fallback in
table.html URL resolution. That's a tighter file-path predicate
than the cascade walker would naturally express; can revisit if it
ever needs to become configurable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coupled fixes:
1. landing MDL card: Open button now navigates to /<project>/archive/
<party>/mdl (no trailing slash) so the tables tool loads. The
slash form would route to browse instead, which is not what users
want when they click "Open MDL".
2. zddc-server canonical-folder fallback extended to
archive/<party>/{mdl,incoming,received,issued}. New
zddc.IsArchivePartyFolder() recognises any of the four party
folders at depth 4. fs.ListDirectory returns [] for missing
on-disk variants (mirroring the project-root behavior added in
commit 3fc3717); the dispatcher routes slash forms to
ServeDirectory and the no-slash mdl form to ServeTable, with
non-mdl no-slash forms 302'ing to the slash form.
So /Project-N/archive/<party>/incoming/ now lands on an empty
browse listing rather than 404 when nobody has dropped files yet.
3. Fixture seeded with 3 files per party under incoming/ — naming
intentionally NOT in transmittal-envelope form, so classifier
(loaded automatically by browse's grid mode at /incoming/
per the URL-driven view convention) has something to rename.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related routing fixes:
1. /<project>/archive/<party>/mdl[/] now follows the slash/no-slash
convention uniformly with the rest of the system:
- mdl (no slash) → tables app (default tool for mdl/)
- mdl/ (slash) → browse (ServeDirectory empty-listing fallback)
Previously the slash form auto-redirected to mdl/table.html, which
forced the user into the table view from any party-folder click and
produced a confusing "Unrecognized table URL" error when the
redirect race-conditioned. tableRowsRedirect now only redirects
when a real on-disk table.yaml exists; the default-MDL virtual case
stays in browse via the convention.
New zddc.IsArchivePartyMdlDir helper recognises the canonical
<project>/archive/<party>/mdl pattern at depth 4 (relative path).
fs.ListDirectory uses it to return [] for the missing-on-disk case
so browse renders the empty workspace cleanly. Test updated
(TestServeDirectoryRedirectsDefaultMdl → TestServeDirectoryDefaultMdlNoRedirect).
2. <dir>/.zddc URLs now work at every directory depth.
The dispatcher previously 404'd anything beginning with a dot
(except /.archive and /<dir>/.zddc.html). New IsZddcFileRequest +
ServeZddcFile handlers carve out the raw .zddc leaf so an operator
can navigate to /Project-1/archive/PartyA/mdl/.zddc and inspect
the rules effective at that depth.
Semantics:
- Method: GET / HEAD only. Writes go through the existing admin-
gated form at <dir>/.zddc.html (unchanged).
- ACL: parent directory's read permission gates access; 404
(not 403) is returned to non-readers so existence isn't leaked.
- On disk: file bytes served verbatim with
Content-Type: application/yaml and X-ZDDC-Source: file:<rel>.
- Virtual: when no file exists at this level, a synthetic
placeholder body is returned with a YAML-comment cascade
summary so the reader sees exactly what rules apply here from
ancestors. X-ZDDC-Source: virtual:zddc distinguishes it.
The virtual body parses as valid YAML (`{}` after the comments) so
downstream tooling that consumes the URL isn't confused.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a user first writes to <project>/working/<email>/, the auto-own
.zddc EnsureCanonicalAncestors seeds at that folder now sets
acl.inherit: false in addition to the rwcda grant. This makes each
user's working subtree private by default — ancestor cascade grants
(e.g. a permissive *: r at the project root) no longer let anyone
read everyone else's drafts.
Implements the user-stated sandbox model: "no automatic or default
permissions other than the user's default folder which is instantiated
on first save — users can edit the .zddc files in their subtree to
allow access to others." The owner can edit
<project>/working/<email>/.zddc to add collaborators (or set
inherit: true, or list specific email patterns).
Mechanics:
- new WriteAutoOwnZddcFenced — same shape as WriteAutoOwnZddc plus
acl.inherit: false. Existing WriteAutoOwnZddc unchanged.
- autoOwnDepthMatch returns (autoOwn, fenced); idx 2 under working/
triggers fenced=true. The other auto-own positions
(depth 1: working/staging/, depth 3: archive/<party>/incoming/)
stay unfenced — those are shared lanes where ancestor admin
grants should still apply.
- staging/ children stay unfenced because staging folders are
date+tracking-named (shared lane), not per-user.
Tests:
- TestEnsureCanonicalAncestors_LazyCreation now asserts the fenced
.zddc exists at working/<email>/ with inherit: false.
- TestEnsureCanonicalAncestors_StagingChildNotFenced new — staging
children stay plain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Listing <project>/{archive,working,staging,reviewing}/ when the folder
doesn't exist on disk now returns an empty 200 listing instead of 404.
The stage-strip nav links into these folders unconditionally; without
this fallback, clicking "Working" against a fresh project (where
working/ hasn't been written to yet) lands on a 404 page rather than
a usable empty view.
Mechanism stays consistent with the existing lazy-folder design:
- GET on missing canonical folder → 200 + empty listing (this commit)
- first WRITE under the same path → EnsureCanonicalAncestors
materialises the on-disk folder + auto-own .zddc
reviewing/ stays virtual-only (in VirtualOnlyCanonicalNames); the
fallback just makes its empty listing always renderable. The future
reviewing/ aggregator (recorded in project memory) will replace the
empty listing with the join-computed virtual entries.
The fallback is gated on IsProjectRootFolder — only depth-2 paths
matching one of the four canonical names. Non-canonical missing paths
still 404 (TestListDirectory_NonCanonicalMissing_StillNotFound).
For working/ specifically the synthetic <viewer-email>/ home entry
still fires from virtualUserHomeEntry, so the user sees their own
placeholder even when working/ doesn't exist yet — first write into
that placeholder triggers the lazy-create chain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BREAKING CHANGE. Project-level Issued/Received/Incoming folders no
longer carry special semantics. WORM enforcement and auto-ownership
move to the per-party canonical layout:
- WORM mask now triggers on archive/<party>/received/ and
archive/<party>/issued/ (any case, any party)
- Auto-own .zddc writes on first mkdir under working/, staging/,
or archive/<party>/incoming/ (any case)
Predicate API:
- IsAutoOwnPath(parentDir, fsRoot) — replaces IsAutoOwnParent(name)
- IsWormPath(requestPath) — same name, new pattern
- WormFolderLevelIndex unchanged signature, new pattern
Legacy SpecialFolderNames / AutoOwnFolderNames / WormFolderNames /
IsAutoOwnParent are deleted (no Deprecated: stubs — early-development
project, no back-compat to preserve).
Tool availability (apps/availability.go) is case-fold throughout:
- mdedit: descendants of working/
- transmittal: descendants of staging/
- classifier: descendants of working/, staging/, or
archive/<party>/incoming/
Working/, WORKING/, working/ all match identically.
Test fixtures rewritten:
- special_test.go: covers IsAutoOwnPath / IsWormPath /
WormFolderLevelIndex / ResolveCanonical / canonical lists
- availability_test.go: per-party rules, case-fold scenarios
- fileapi_test.go: rolePermissionsTestSetup now seeds
Project-X/archive/Acme/{incoming,issued,received}/ rather than
Vendor/{Incoming,Issued,Received}/ at the project root
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure refactor. The mkdir post-hook in handler/fileapi.go duplicated
zddc-package types; lifting the body into the package itself lets the
upcoming EnsureCanonicalAncestors helper share it without re-exposing
the file API's internals.
No behaviour change. The grant shape (creator email → rwcda + CreatedBy
audit field) and the atomic-write path through zddc.WriteFile are
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce the lowercase canonical folder model that the new auto-create
feature will key off:
- ProjectRootFolders = [archive, working, staging, reviewing]
- PartyFolders = [mdl, incoming, received, issued]
- AutoOwnCanonicalNames = [working, staging, incoming]
- VirtualOnlyCanonicalNames = [reviewing]
ResolveCanonical(parentDir, logical) does a case-fold lookup against
os.ReadDir(parentDir) so a manually-created Working/ is reused rather
than shadowed by a new working/ sibling.
Pure addition. The existing SpecialFolderNames / AutoOwnFolderNames /
WormFolderNames are kept (now Deprecated:) so dependent packages keep
compiling until the predicate rewrite lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the binary acl.allow/deny model with five permission verbs
(r/w/c/d/a) and first-class roles, and adds an authenticated file API
(PUT/DELETE/POST move/mkdir) so the HTML tools can edit-in-place over
HTTP. Closes the AC-3(7) and AC-6 federal-readiness gaps.
File API (zddc/internal/handler/fileapi.go)
- PUT <new> → action c
- PUT <existing> → action w
- PUT <.zddc> → action a (CanEditZddc strict-ancestor rule)
- DELETE → action d
- POST mkdir → action c (auto-writes creator-owned .zddc when the
parent is Incoming/Working/Staging)
- POST move → action w on src + c on dst, atomic via os.Rename
- Optional If-Match for optimistic concurrency, --max-write-bytes cap,
audit log emits a structured file_write event per operation.
Permission model (zddc/internal/zddc/{acl,file,roles,cascade_mode}.go)
- acl.permissions: { principal → verb-set } map; principals are email
patterns or role names. Empty verb set is an explicit deny.
- roles: { name → members } definitions, available at the level they
declare and all descendants. Closer-to-leaf shadows ancestor.
- Legacy acl.allow/deny still work; they fold into permissions at
parse time (allow → "rwcd", deny → "").
- Cascade walks leaf→root; first level with any matching entry wins;
the union of matching verb sets at that level decides.
- --cascade-mode=strict adds a root→leaf ancestor-deny pre-pass so an
ancestor explicit-deny is absolute (NIST AC-6). Default delegated
preserves the existing commercial behavior.
Special folders (zddc/internal/zddc/special.go)
- Incoming / Working / Staging: mkdir auto-writes a .zddc into the new
subdir granting created_by + that email rwcda directly. Same form
operators write by hand; creator can edit it later to add others.
- Issued / Received: server-enforced WORM split. Cascade grants
inherited from above the WORM folder are masked to r only; grants
placed at-or-below the WORM folder retain r,c. Operators grant
write-once (cr) to the doc controller via an explicit .zddc at the
Issued/Received folder. Admins exempt — only escape hatch.
Browser polyfill (shared/zddc-source.js)
- HttpDirectoryHandle + HttpFileHandle implement the FS Access API
surface (values, getFileHandle, createWritable, removeEntry,
queryPermission/requestPermission) over zddc-server's listing JSON
and file API. Existing tools written against showDirectoryPicker
work unchanged.
- detectServerRoot() returns { handle, status }: tools auto-load on
HTTP, surface a clear "no permission to list" message on 403, and
fall back to the welcome screen on 0.
- classifier renames take the atomic POST move path on HTTP-backed
handles; mdedit and transmittal route reads/writes through the
polyfill so prior FS-API code paths cover both modes.
Tests
- zddc/internal/zddc/{cascade_mode,roles,special,acl}_test.go cover
delegated vs strict, role membership / shadowing / legacy fallback,
WORM split semantics, verb-set parser round-trip.
- zddc/internal/handler/fileapi_test.go now also covers role-based
vendor scenarios, WORM blocking vendor & doc controller writes,
explicit Issued .zddc unlocking the cr drop-box, admin bypass,
auto-ownership on mkdir, and strict-mode lockouts.
Docs
- ARCHITECTURE.md + zddc/README.md document the verb model, role
syntax, special-folder behaviors, cascade-mode flag, and full file
API surface. Federal-readiness gap analysis strikes AC-3(7) and
AC-6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>