From 9aa587aac0911c1b5214c79d2011b2f17cbc2d1d Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 12 May 2026 10:29:44 -0500 Subject: [PATCH] =?UTF-8?q?feat(zddc):=20incoming/=20is=20a=20controlled?= =?UTF-8?q?=20drop=20zone=20=E2=80=94=20project=5Fteam=20read-only,=20doc?= =?UTF-8?q?=20controller=20QCs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify the incoming/ semantics per the workflow: it's the counterparty's drop zone, not a free-for-all. - project_team gets read only here (inherited from the project level — they have no c/w, so they can see what's been dropped but not touch it). No change in effect; documented explicitly. - document_controller gets rwcd here (restated at the incoming/ cascade level). The QC + transfer workflow — classifier renames files in place (w), then they move to received/ (delete here + worm-create there) — needs the delete bit, which the inherited project-level `rw` lacked. - The counterparty's uploader still gets access via a deployment .zddc (acl: { permissions: { "*@acme.com": cr } } at archive/Acme/incoming/.zddc) or by mkdir'ing a dated subfolder under incoming/ and owning it via the existing auto_own — both flows unchanged. Test: standardroles_test now asserts the doc controller has rwcd at incoming/ and a project_team member has only r there. All Go + Playwright tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/handler/tables.html | 2 +- zddc/internal/zddc/defaults.zddc.yaml | 23 ++++++++++++++++++++--- zddc/internal/zddc/standardroles_test.go | 8 ++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 2bf3c40..64bb15d 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1300,7 +1300,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-12 15:16:28 · 2de2fdf-dirty + v0.0.17-alpha · 2026-05-12 15:29:24 · 54dff4d-dirty
diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 177887d..7b79559 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -119,13 +119,30 @@ paths: # spec even when the on-disk folder doesn't exist. virtual: true incoming: + # incoming/ is the COUNTERPARTY's drop zone. The flow: + # 1. the other party's document controller uploads + # files here (a deployment grants them cr at this + # path, e.g. acl: { permissions: { "*@acme.com": cr } } + # at archive/Acme/incoming/.zddc — or they mkdir a + # dated subfolder under incoming/ and own it via + # auto_own) + # 2. OUR document controller QCs them via classifier + # (rename in place) and moves them to received/ + # (which needs delete here + worm-create there), + # ideally returning a signed transmittal in issued/ + # + # The normal project_team has only read here (inherited + # from the project level — they have no c/w) so they can + # see what's been dropped but not touch it. The + # document_controller grant restates rwcd so the QC + + # transfer-out workflow has the delete bit it needs. default_tool: classifier available_tools: [classifier] - # First write into incoming/ auto-creates an owner - # grant so the creator can manage their own drops. auto_own: true - # Browse shows a drag-drop overlay here. drop_target: true + acl: + permissions: + document_controller: rwcd # received/ and issued/ are WORM (write-once-read-many). # The `worm:` list marks the zone: # diff --git a/zddc/internal/zddc/standardroles_test.go b/zddc/internal/zddc/standardroles_test.go index 60ac1eb..8ced4ab 100644 --- a/zddc/internal/zddc/standardroles_test.go +++ b/zddc/internal/zddc/standardroles_test.go @@ -50,6 +50,8 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) { mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw") // archive/: rwc (can create party folders). mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc") + // incoming/: rwcd — the QC + transfer-out workflow needs delete. + mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "incoming"), "rwcd") // received/ (WORM): rw masked to r, plus worm-restored c → "rc". mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "received"), "rc") mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "rc") @@ -117,4 +119,10 @@ created_by: alice@example.com if got := EffectiveVerbs(chain2, alice, ModeDelegated); got.String() != "r" { t.Errorf("alice in archive/Acme = %q, want r", got.String()) } + // Alice CANNOT write to incoming/ — that's the counterparty's drop + // zone, QC'd by the document controller. project_team gets read only. + chain3, _ := EffectivePolicy(root, filepath.Join(root, "Proj", "archive", "Acme", "incoming")) + if got := EffectiveVerbs(chain3, alice, ModeDelegated); got.String() != "r" { + t.Errorf("alice in incoming/ = %q, want r (no create/write for project_team)", got.String()) + } }