diff --git a/AGENTS.md b/AGENTS.md index 3606c4b..1827b63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -494,14 +494,22 @@ roles: - auditor@regulator.gov ``` -The embedded cascade already grants `project_team: r` and `observer: r` project-wide, and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, subtree-admin of every `archive//` so they own each party's lifecycle slots — `working/`, `staging/`, `reviewing/`, `incoming/`). Populating role members lights all of that up. Plan-Review approval is part of the `document_controller` role by design — there is no separate `approver` role; two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides. The three standard roles' invariants are locked down in `zddc/internal/zddc/standardroles_test.go`. +The embedded cascade already grants `project_team: r` and `observer: r` project-wide, and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, `rwcd` at `incoming/` and `staging/` for the QC + transfer workflows). When DC creates an `archive//` folder the auto-own `.zddc` written there grants both their email AND the `document_controller` role `rwcda` (via `auto_own_roles: [document_controller]` in the defaults) — so any peer DC has full authority at every party without needing subtree-admin status. Plan-Review approval is part of the `document_controller` role by design — there is no separate `approver` role; two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides. The three standard roles' invariants are locked down in `zddc/internal/zddc/standardroles_test.go`. + +The in-flight lifecycle slots form a one-way ratchet: + + `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 `/` 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). 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/`. -- `project_team` — day-to-day contributor. Read across the project; full control of their own `archive//working//` home via auto-own with a fenced `.zddc` (`inherit: false`). +- `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. +- `project_team` — day-to-day contributor. Reads across the project; ratchets through the in-flight slots. Owns their `archive//working//` home via auto-own with a fenced `.zddc` (`inherit: false`). - `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.) + **Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`): - `acl: { permissions: { : }, inherit: ? }` — there is no `allow:` key; an `allow:` block parses cleanly but is silently dropped during unmarshal. Real footgun — easy to write `acl: { allow: [...] }` and assume it works. @@ -509,8 +517,18 @@ Pick a role per persona: - Principals: email (must contain `@`), glob (`*@domain.com`), or role name (no `@`). - `roles: { : { members: [...], reset: ? } }` — members union across the cascade unless `reset: true`. - `admins: [, ...]` — root only; sudo-style elevation per request. +- `auto_own: ` — when true, ensure.go writes a `.zddc` granting the creator's email `rwcda` on first mkdir. +- `auto_own_fenced: ` — adds `inherit: false` to the auto-own `.zddc` (private-by-default home). No effect without `auto_own: true`. +- `auto_own_roles: [, ...]` — 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. +**Two `inherit` scopes, one word.** `ZddcFile.Inherit` (top-level) drops the embedded baseline AND fences ancestor on-disk `.zddc` files from this point of the cascade. `ACLRules.Inherit` (nested under `acl:`) is narrower — it only fences ACL evaluation; embedded roles, paths-tree contributions, WORM lists, and other non-ACL keys still cascade through. Concretely: + +- To opt out of embedded defaults at deployment, set `inherit: false` at the root `/.zddc` (top-level). +- To make a per-user home private (block ancestor read grants) but keep cascade-derived behaviour like default_tool, set `acl: { inherit: false }`. The auto-own-fenced mechanism uses this form. + +These are NOT interchangeable. A note about which one operators want lives in `cascade.go:13-21` (the `PolicyChain` doc) and the relevant struct fields in `file.go`. + Run `zddc-server show-defaults` to dump the embedded `defaults.zddc.yaml` with annotated comments — that's the full schema with all the cascade keys (`worm:`, `auto_own:`, `drop_target:`, `apps:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.). ### Build diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9a9a204..0490724 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -715,7 +715,7 @@ The schema keys that drive built-in behavior: **Standard roles.** `defaults.zddc.yaml` references three roles (all shipped empty — a fresh deployment grants nothing until an operator populates them): -- `document_controller` — read/write across a project, `rwc` at `archive/`, subtree-admin of every `archive//` and its in-flight slots, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow. Plan-Review approval is part of this role; there is no separate `approver` — two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides rather than baked-in roles. +- `document_controller` — read/write across a project, `rwc` at `archive/`. When a DC mkdir's `archive//`, the auto-own `.zddc` grants both their email AND the `document_controller` role `rwcda` at that party (via `auto_own_roles: [document_controller]` in the defaults) — so any peer DC has full authority at every party without needing subtree-admin status. Explicit `rwcd` at `incoming/` and `staging/` shadows the inherited `rwcda` to make the transfer-workflow's `d` requirement obvious. WORM-create principal in `received/issued` via the `worm:` list. NOT a subtree-admin anywhere — admin elevation is reserved for the root `admins:` list (the human escape hatch). Plan-Review approval is part of this role; there is no separate `approver` — two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides rather than baked-in roles. - `project_team` — read-only across the project; their own `archive//working//` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule. - `observer` — pure read-only across the project. Distinct from `project_team` in that the role itself carries no `c` anywhere, so an observer can't bring a working home into existence under auto-own. Intended for auditors, regulators, and external read-only viewers who must not contribute content. diff --git a/zddc/internal/handler/planreview_test.go b/zddc/internal/handler/planreview_test.go index 881483f..0ae91e8 100644 --- a/zddc/internal/handler/planreview_test.go +++ b/zddc/internal/handler/planreview_test.go @@ -23,11 +23,16 @@ func planReviewSetup(t *testing.T) (config.Config, func(target, email string, bo t.Helper() root := t.TempDir() - // Root .zddc grants alice subtree-admin everywhere AND sets the - // document_controller role so the cascade's reviewing/+staging/ - // admin grants resolve to her. The role membership also confers - // `c` authority on received/ via the WORM list in the defaults, - // which Plan Review's pre-flight requires. + // Root .zddc grants alice root-admin AND adds her to the + // document_controller role. The root-admin status + elevated + // principal (set on the request below) is what carries her past + // Plan Review's ActionAdmin checks — DCs are no longer subtree- + // admin by default; their party-level `a` verb comes from the + // auto-own .zddc that ensure.go writes when they mkdir + // archive// (carrying auto_own_roles: [document_controller] + // from the defaults). This fixture uses root-admin to keep the + // test self-contained without scaffolding a party folder; the + // non-root-admin DC path is covered by the standardroles tests. mustWriteHelper(t, filepath.Join(root, ".zddc"), "admins:\n - alice@example.com\n"+ "roles:\n document_controller:\n members: [alice@example.com]\n") diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 844e9c8..f6ba0ec 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -397,9 +397,19 @@ paths: # working/ root stays readable to all team members # (cascade is per-level deepest-match — a single `c` # would shadow the project-level `r`). + # + # `document_controller: rwcda` is restated here so a + # DC whose email is ALSO matched by project_team + # (typical when project_team is `*@example.com`) gets + # the higher grant via within-level union. Without + # the restatement, the cascade's deepest-level-wins + # would pick project_team's cr and shadow the DC's + # rwcda inherited from the party's auto-own .zddc. + # Same pattern applied at staging/ and reviewing/. acl: permissions: project_team: cr + document_controller: rwcda # working/ auto-owns the first creator + the per-user # homes below. auto_own: true @@ -434,13 +444,16 @@ paths: # mkdir flow; project_team can keep to file drops to # honour the "can't alter after" intent. # - # DC gets rwcd explicitly — the staging-to-issued - # transfer needs `d` (cut, not copy) to move files - # out. Mirrors the incoming/ pattern at line 286-288. + # DC gets rwcda explicitly — `d` for the cut to issued/, + # `a` so Plan Review can write the staging//.zddc + # the composite endpoint scaffolds. Restated here (not + # inherited from the party-level role grant) so the + # within-level union dominates project_team's cr for + # any DC matched by the team wildcard. acl: permissions: project_team: cr - document_controller: rwcd + document_controller: rwcda auto_own: true drop_target: true reviewing: @@ -462,8 +475,14 @@ paths: # here, unlike working/) gives the creator rwcda # inside; siblings see the iteration via the project- # level project_team:r cascade. + # + # document_controller: rwcda restated for the same + # reason as working/ + staging/ — keeps a DC matched + # by the project_team wildcard at full authority via + # within-level union. acl: permissions: project_team: cr + document_controller: rwcda auto_own: true drop_target: true diff --git a/zddc/internal/zddc/standardroles_test.go b/zddc/internal/zddc/standardroles_test.go index 50a11cf..f3e275c 100644 --- a/zddc/internal/zddc/standardroles_test.go +++ b/zddc/internal/zddc/standardroles_test.go @@ -25,16 +25,18 @@ import ( func TestStandardRoles_DocControllerScopedCreate(t *testing.T) { resetCache() root := t.TempDir() - // Note: project_team's wildcard would normally also match dc@, - // which would shadow the role's rwcda at working/ (where the slot - // explicitly grants project_team:cr). Real deployments keep - // document_controller and project_team disjoint; the test fixture - // mirrors that. + // DCs are typically internal employees and ARE in project_team + // (which is commonly defined as the *@example.com wildcard). The + // embedded defaults restate document_controller:rwcda at every + // slot that grants project_team a narrower verb set; the + // cascade's within-level union then gives the DC the higher + // grant. This fixture mirrors the realistic deployment shape so + // the union behavior is actually exercised. writeZddc(t, root, `roles: document_controller: members: ["dc@example.com"] project_team: - members: ["alice@example.com"] + members: ["*@example.com"] `) // Simulate the auto-own .zddc the file API writes when DC mkdir's // archive/Acme/. Carries the creator email + the document_controller @@ -82,10 +84,12 @@ created_by: dc@example.com // party-level role grant where no slot-local grant overrides. mustVerbs(filepath.Join(partyDir, "working"), "rwcda") mustVerbs(filepath.Join(partyDir, "reviewing"), "rwcda") - // incoming/ and staging/ have explicit document_controller: rwcd + // incoming/ has explicit document_controller: rwcd // — leaf-wins shadows the rwcda inherited from /. mustVerbs(filepath.Join(partyDir, "incoming"), "rwcd") - mustVerbs(filepath.Join(partyDir, "staging"), "rwcd") + // staging/ has explicit document_controller: rwcda (rwcd for + // transfer + `a` for Plan Review's staging//.zddc). + mustVerbs(filepath.Join(partyDir, "staging"), "rwcda") // received/ (WORM): inherited rwcda masked to r + worm-restored c. mustVerbs(filepath.Join(partyDir, "received"), "rc") mustVerbs(filepath.Join(partyDir, "issued"), "rc")