feat(zddc): incoming/ is a controlled drop zone — project_team read-only, doc controller QCs

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-12 10:29:44 -05:00
parent 54dff4dcd3
commit 9aa587aac0
3 changed files with 29 additions and 4 deletions

View file

@ -1300,7 +1300,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-12 15:16:28 · 2de2fdf-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-12 15:29:24 · 54dff4d-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -119,13 +119,30 @@ paths:
# spec even when the on-disk folder doesn't exist. # spec even when the on-disk folder doesn't exist.
virtual: true virtual: true
incoming: 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 default_tool: classifier
available_tools: [classifier] available_tools: [classifier]
# First write into incoming/ auto-creates an owner
# grant so the creator can manage their own drops.
auto_own: true auto_own: true
# Browse shows a drag-drop overlay here.
drop_target: true drop_target: true
acl:
permissions:
document_controller: rwcd
# received/ and issued/ are WORM (write-once-read-many). # received/ and issued/ are WORM (write-once-read-many).
# The `worm:` list marks the zone: # The `worm:` list marks the zone:
# #

View file

@ -50,6 +50,8 @@ func TestStandardRoles_DocControllerScopedCreate(t *testing.T) {
mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw") mustVerbs(filepath.Join(root, "Proj", "random-folder"), "rw")
// archive/: rwc (can create party folders). // archive/: rwc (can create party folders).
mustVerbs(filepath.Join(root, "Proj", "archive"), "rwc") 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". // 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", "received"), "rc")
mustVerbs(filepath.Join(root, "Proj", "archive", "Acme", "issued"), "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" { if got := EffectiveVerbs(chain2, alice, ModeDelegated); got.String() != "r" {
t.Errorf("alice in archive/Acme = %q, want r", got.String()) 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())
}
} }