docs: fix stale "fenced/private home" claims — default homes are shared

The auto_own_fenced mechanism (private per-creator home via inherit:false) still
exists, but the current default tree sets it NOWHERE — the working/staging/
incoming/reviewing <party> homes are auto_own but UNFENCED, so ancestor grants
(project_team: cr at working/) cascade in and they are shared team folders. Code
comments (file.go AutoOwnFenced, special.go WriteAutoOwnZddcFenced, ensure.go,
fileapi.go) and AGENTS.md (role model + the auto_own_fenced key) still described
per-user homes as fenced/private-by-default — a pre-reshape artifact.

Correct them: fencing is an opt-in not used by the default tree; the party homes
are unfenced/shared. No behavior change (grep finds no auto_own_fenced in
internal/zddc/defaults). From the deferred-findings triage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-09 19:57:13 -05:00
parent 88ef2dd921
commit 84c1b58b66
5 changed files with 38 additions and 32 deletions

View file

@ -526,12 +526,12 @@ The in-flight lifecycle slots form a one-way ratchet:
`working/``staging/``issued/` (WORM) `working/``staging/``issued/` (WORM)
Each handoff drops `project_team`'s modify rights for the slot they pushed from. At `working/` they have `cr` plus `rwcda` inside their auto-own-fenced `<email>/` home. At `staging/` they have `cr` only (drop files, no modify after). DC takes over with `rwcd` at staging and files to `issued/` via the WORM `cr` grant. Same shape on the inbound side via `incoming/``received/` (WORM). Each handoff drops `project_team`'s modify rights for the slot they pushed from. At `working/` they have `cr` plus `rwcda` inside the `<party>/` folder they create (auto-owned but **unfenced**`working/` is a shared team space, so peers keep their `cr` there). At `staging/` they have `cr` only (drop files, no modify after). DC takes over with `rwcd` at staging and files to `issued/` via the WORM `cr` grant. Same shape on the inbound side via `incoming/``received/` (WORM).
Pick a role per persona: Pick a role per persona:
- `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`. **NOT a subtree-admin** anywhere — authority comes purely from cascade grants (the role-level `rwcda` written by `auto_own_roles` at each party, plus explicit `rwcd` at `incoming/` and `staging/`). They cannot bypass WORM (only worm-create via the list) or reach inside fenced working homes. - `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`. **NOT a subtree-admin** anywhere — authority comes purely from cascade grants (the role-level `rwcda` written by `auto_own_roles` at each party, plus explicit `rwcd` at `incoming/` and `staging/`). They cannot bypass WORM (only worm-create via the list).
- `project_team` — day-to-day contributor. Reads across the project; ratchets through the in-flight slots. Owns their `archive/<party>/working/<email>/` home via auto-own with a fenced `.zddc` (`inherit: false`). - `project_team` — day-to-day contributor. Reads across the project; ratchets through the in-flight slots. Auto-owns (`rwcda`) the `working/<party>/` folder they create; it is **unfenced**, so `working/` stays a shared team space (every `project_team` member keeps cascade `cr` there). A per-directory `auto_own_fenced` opt-in (not set in the default tree) would make it private.
- `observer` — pure read-only across the project. No auto-own home (the role itself has no `c` anywhere). Intended for auditors, regulators, and external read-only viewers who must not contribute content. - `observer` — pure read-only across the project. No auto-own home (the role itself has no `c` anywhere). Intended for auditors, regulators, and external read-only viewers who must not contribute content.
**Roles overlap on purpose.** DCs are typically internal employees and ARE in `project_team` (often defined as `*@example.com`). The cascade is "deepest level that has any matching principal wins for that level, with within-level UNION of all matched principals". To prevent a `project_team: cr` grant at the slot from shadowing a DC's role-level `rwcda` inherited from the party folder, the embedded defaults RESTATE `document_controller: rwcda` at every slot that has a project_team-specific grant (`working/`, `staging/`, `reviewing/`). Within-level union → DC gets `rwcda` `cr` = `rwcda`. Operators adding new slot-level project_team grants in their own `.zddc` files should follow the same pattern. (Internal `observer` users matched by the project_team wildcard would still be lifted to `cr` by the union — observer is intended for EXTERNAL auditors whose emails don't match the wildcard. Deployments with internal observers should use explicit project_team membership instead of a wildcard.) **Roles overlap on purpose.** DCs are typically internal employees and ARE in `project_team` (often defined as `*@example.com`). The cascade is "deepest level that has any matching principal wins for that level, with within-level UNION of all matched principals". To prevent a `project_team: cr` grant at the slot from shadowing a DC's role-level `rwcda` inherited from the party folder, the embedded defaults RESTATE `document_controller: rwcda` at every slot that has a project_team-specific grant (`working/`, `staging/`, `reviewing/`). Within-level union → DC gets `rwcda` `cr` = `rwcda`. Operators adding new slot-level project_team grants in their own `.zddc` files should follow the same pattern. (Internal `observer` users matched by the project_team wildcard would still be lifted to `cr` by the union — observer is intended for EXTERNAL auditors whose emails don't match the wildcard. Deployments with internal observers should use explicit project_team membership instead of a wildcard.)
@ -544,7 +544,7 @@ Pick a role per persona:
- `roles: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`. - `roles: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`.
- `admins: [<email>, ...]` — root only; sudo-style elevation per request. - `admins: [<email>, ...]` — root only; sudo-style elevation per request.
- `auto_own: <bool>` — when true, ensure.go writes a `.zddc` granting the creator's email `rwcda` on first mkdir. - `auto_own: <bool>` — when true, ensure.go writes a `.zddc` granting the creator's email `rwcda` on first mkdir.
- `auto_own_fenced: <bool>` — adds `inherit: false` to the auto-own `.zddc` (private-by-default home). No effect without `auto_own: true`. - `auto_own_fenced: <bool>` — adds `inherit: false` to the auto-own `.zddc`, making the directory private to its creator (ancestor grants don't cascade in). **Opt-in — not set anywhere in the default tree**, so the default working/staging/incoming/reviewing party homes are unfenced/shared. No effect without `auto_own: true`.
- `auto_own_roles: [<role>, ...]` — additional role names that get `rwcda` in the auto-own `.zddc`, alongside the creator's email. Lets the schema express role-level peer authority without `admins:` (which would be subtree-admin and bypass WORM/fences via elevation). - `auto_own_roles: [<role>, ...]` — additional role names that get `rwcda` in the auto-own `.zddc`, alongside the creator's email. Lets the schema express role-level peer authority without `admins:` (which would be subtree-admin and bypass WORM/fences via elevation).
- `title:` — read only from the per-project `.zddc`; surfaces on the landing-page picker. - `title:` — read only from the per-project `.zddc`; surfaces on the landing-page picker.

View file

@ -821,10 +821,12 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// - abs's parent is declared auto_own — every child mkdir under // - abs's parent is declared auto_own — every child mkdir under
// an auto-own folder (working/, staging/, archive/<party>/, // an auto-own folder (working/, staging/, archive/<party>/,
// archive/<party>/incoming/, …) gets the creator's grant. // archive/<party>/incoming/, …) gets the creator's grant.
// The fence (inherit:false) follows abs's own cascade level: // The fence (inherit:false) follows abs's own cascade level via
// per-user homes under working/ declare auto_own_fenced, so the // AutoOwnFencedAt. It is an opt-in the default tree does not set —
// generated .zddc is private; other auto-own positions are // the working/staging/incoming/reviewing party homes are auto-owned
// unfenced so ancestor grants still cascade through. // but UNFENCED, so ancestor grants (e.g. project_team cr) cascade
// through and they behave as shared team folders. An operator can
// set auto_own_fenced on a position to make it private.
if email != "" { if email != "" {
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) { if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
roles := zddc.AutoOwnRolesAt(cfg.Root, abs) roles := zddc.AutoOwnRolesAt(cfg.Root, abs)

View file

@ -200,10 +200,11 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
// Determine if this newly-created ancestor is an auto-own // Determine if this newly-created ancestor is an auto-own
// position and whether it should be fenced (inherit: false). // position and whether it should be fenced (inherit: false).
// Resolved via the .zddc cascade — internal/zddc/defaults/ // Resolved via the .zddc cascade — the embedded defaults
// carries the canonical "working/staging auto-own + per-user // (internal/zddc/defaults/) declare auto_own at the working/
// homes fenced + incoming auto-own" convention, and any // staging/ incoming/ reviewing/ <party> homes but do NOT fence
// on-disk .zddc can override per-directory. // them (they are shared team folders); an on-disk .zddc can opt
// a directory into fencing per-directory with auto_own_fenced.
_ = parentSegs // depth-tracking no longer needed _ = parentSegs // depth-tracking no longer needed
_ = i _ = i
autoOwn := AutoOwnAt(fsRoot, pathSoFar) autoOwn := AutoOwnAt(fsRoot, pathSoFar)
@ -222,12 +223,11 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
// Seed auto-own .zddc on the canonical positions that were freshly // Seed auto-own .zddc on the canonical positions that were freshly
// created. Skip if no principal email is available (anonymous or // created. Skip if no principal email is available (anonymous or
// system writes). The fenced variant is used at per-user home // system writes). The fenced variant (inherit:false, private to the
// folders under working/ — private by default; owner can later // creator) is an opt-in the default tree does not use — see
// edit the .zddc to add collaborators. Role grants (from the // AutoOwnFencedAt. Role grants (from the cascade's auto_own_roles
// cascade's auto_own_roles list) are written alongside the // list) are written alongside the creator email so role-level peer
// creator email so role-level peer authority survives without // authority survives without needing a subtree-admin grant.
// needing a subtree-admin grant.
if principalEmail != "" { if principalEmail != "" {
for _, c := range freshlyCreated { for _, c := range freshlyCreated {
if !c.autoOwn { if !c.autoOwn {

View file

@ -228,12 +228,15 @@ type ZddcFile struct {
// created. Empty (nil) inherits via cascade. // created. Empty (nil) inherits via cascade.
AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"` AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"`
// AutoOwnFenced augments AutoOwn: when true, the generated .zddc // AutoOwnFenced augments AutoOwn: when true, the generated .zddc is
// is written with `inherit: false` so the new directory is // written with `inherit: false` so the new directory is private to its
// private to its creator (ancestor ACL grants don't apply). Used // creator (ancestor ACL grants don't cascade in). It is an OPT-IN an
// for per-user home folders under working/<email>/. Default // operator can set on any auto_own position; the current embedded default
// (nil/false) writes a non-fenced auto-own .zddc — ancestor // tree does NOT set it anywhere — the working/ staging/ incoming/
// admin grants still apply. // reviewing/ <party> homes are auto-owned but UNFENCED, so ancestor grants
// (e.g. `project_team: cr` at working/) still cascade in, making them
// shared team folders rather than private per-user sandboxes. Default
// (nil/false) writes a non-fenced auto-own .zddc.
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"` AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
// AutoOwnRoles augments AutoOwn with role-level grants: when set, // AutoOwnRoles augments AutoOwn with role-level grants: when set,

View file

@ -32,20 +32,21 @@ func WriteAutoOwnZddc(dir, principalEmail string, roles []string) error {
} }
// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally // WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
// sets `acl.inherit: false` — fencing ancestor cascade grants. Used at // sets `acl.inherit: false` — fencing ancestor cascade grants so the new
// per-user home folders under working/ where the convention is "private // directory is private to its creator. This is the OPT-IN private-home form:
// by default; owner edits the file to add collaborators." // the current embedded default tree does NOT use it (the working/staging/
// incoming/reviewing party homes are unfenced and shared — see AutoOwnFenced
// in file.go). An operator opts in by setting auto_own_fenced on a position.
// //
// Without the fence, an ancestor `*: r` (e.g. a project-root grant for // With the fence, an ancestor grant (e.g. `project_team: cr` at working/)
// authenticated users) would let any user read every other user's // does NOT cascade in, so only the creator (and any roles passed below) can
// working subfolder via cascade — defeating the per-user sandbox. // reach the directory.
// //
// roles is the same as for WriteAutoOwnZddc — listed roles get rwcda // roles is the same as for WriteAutoOwnZddc — listed roles get rwcda
// alongside the creator, and like the creator grant they're INSIDE // alongside the creator, and like the creator grant they're INSIDE
// the fence (only resolvable if the role is defined at this level or // the fence (only resolvable if the role is defined at this level or
// in chain.Embedded, since ancestor role definitions are hidden by // in chain.Embedded, since ancestor role definitions are hidden by
// inherit:false). Typically callers using the fenced variant pass nil // inherit:false). Callers using the fenced variant typically pass nil roles.
// roles — per-user homes don't need peer authority.
func WriteAutoOwnZddcFenced(dir, principalEmail string, roles []string) error { func WriteAutoOwnZddcFenced(dir, principalEmail string, roles []string) error {
return writeAutoOwn(dir, principalEmail, true, roles) return writeAutoOwn(dir, principalEmail, true, roles)
} }