Commit graph

9 commits

Author SHA1 Message Date
7d462ab7a4 test(zddc): update internal/zddc suite for flat-peer layout
Repoint default-tool/history/canonical-folder/auto-own/virtual/declared,
role-grant, and WORM-zone expectations to the top-level peer layout: archive
is now blanket-WORM (DC = rc there), the workspace/register peers carry the
DC grants directly, and incoming/working/staging/reviewing/mdl/rsk/ssr are
physical peers. ensure_test repointed to top-level paths + the virtual-reject
test inverted (peers are physical now).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:47:04 -05:00
736f422f82 fix(roles): restate document_controller at project_team slot grants
DCs are typically internal employees and ARE in project_team (when
project_team is the realistic *@example.com wildcard). The cascade's
"deepest level that has any matching principal wins" semantic means
a project_team:cr grant at the slot level would shadow the DC's
party-level rwcda — leaving DCs limited to project_team's grant.

Fix: at every slot with a project_team-specific grant, restate
document_controller's role grant. The within-level union of all
matched principals then gives the DC rwcda ∪ cr = rwcda. No cascade
semantics change; just verbose defaults.

  working/   project_team: cr, document_controller: rwcda  (new DC line)
  staging/   project_team: cr, document_controller: rwcda  (upgraded from rwcd —
                                                            adds `a` for
                                                            Plan Review's
                                                            staging/<tracking>/.zddc)
  reviewing/ project_team: cr, document_controller: rwcda  (new DC line)

Test fixture flipped from disjoint-role members to the realistic
project_team: ["*@example.com"]; verifies DC's rwcda survives the
wildcard via within-level union at each slot.

Docs updated:
  - AGENTS.md "Standard roles": describes the role-restate pattern
    + flags the internal-observer-via-wildcard caveat (operators
    needing internal observers should avoid the *@ wildcard for
    project_team).
  - ARCHITECTURE.md "Standard roles": same model description; drops
    the now-incorrect "subtree-admin of every archive/<party>/"
    line, replaces with the auto_own_roles role grant.
  - planreview_test.go fixture comment: reflects that the test
    uses root-admin to bypass ACLs, with non-root-admin DC path
    covered by standardroles tests' auto-own .zddc simulation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:03:42 -05:00
ba98b87b2a feat(roles): in-flight ratchet + auto_own_roles, drop DC subtree-admin
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>
2026-05-21 10:51:07 -05:00
fb50bb5ef6 feat(roles): add observer standard role
A third standard role for auditors, regulators, and external
read-only viewers. Like project_team it gets project-wide `r`, but
unlike project_team the role itself carries no `c` anywhere — so an
observer can't bring a working/<email>/ home into existence under
auto-own, even though the auto-own mechanism is path-keyed rather
than role-keyed.

Approver-by-design: the role audit explicitly rejects a separate
`approver` role. Plan-Review approval stays with document_controller;
two-person sign-off, when needed, is expressed via per-folder `.zddc`
overrides rather than baked-in roles. Comments in defaults.zddc.yaml
and ARCHITECTURE.md call this out so future role audits don't
reopen the question.

TestStandardRoles_ObserverReadOnlyEverywhere locks the invariants:
project-wide r, no c at archive/incoming/working/staging/reviewing,
WORM zones read-only (no worm-create), and not subtree-admin
anywhere even when notionally elevated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:59:44 -05:00
59b5550872 refactor: nest lifecycle slots per-party + add virtual top-level aggregators
May 2026 reshape. archive/ is now the only physical project-root
directory; working/, staging/, reviewing/ move from the project root
into each archive/<party>/ folder. Six top-level URLs become virtual
aggregators served via the cascade rather than disk:

  ssr/mdl/rsk           tables rollups across parties with a
                        synthesised $party source-party column
  working/staging/      browse folder-nav listings of parties with
  reviewing             non-empty content in the slot; per-party
                        URLs 302-redirect to archive/<party>/<slot>/

Mkdir at the project root is restricted to `archive` and `_`/`.`-
prefixed system names — virtual aggregator names and ad-hoc folders
return 409.

Plan Review hardcodes the scaffold convention (archive/<party>/
{reviewing,staging}/<tracking>/); the pre-reshape
on_plan_review.{reviewing_root,staging_root} cascade keys are dropped.

document_controller is now subtree-admin of every archive/<party>/
(not of project-root working/staging/ as before), so per-party
lifecycle slots inherit admin authority through the cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:57:45 -05:00
f196205622 refactor(audit): pre-release cleanup pass
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>
2026-05-18 16:28:07 -05:00
2d114fcb96 refactor: unified listing protocol + form-editor retirement + admin elevation
Three coordinated changes that share the same files. Common theme:
convention beats exception. Where the codebase had a bespoke wire shape
or a special-case route, replace it with the generic shape every other
client already speaks.

== Listing protocol ==

GET / Accept: application/json used to dispatch to a bespoke
ServeProjectList handler returning {name, url, title} per project — a
shape that diverged from every other directory's listing.FileInfo
response. Now:

- listing.FileInfo gains an optional `title` field (read from each
  directory's own .zddc title:). Generic clients (landing, browse)
  read the same shape from every URL.
- appfs.ListDirectory emits a virtual `.zddc` entry (is_dir:false,
  virtual:true) when no on-disk file exists at that path and the
  caller asked for ?hidden=1. Opens an editable view of the cascade
  defaults; PUT-saving its bytes materialises a real file.
- The bespoke GET / JSON branch in cmd/zddc-server/main.go is gone.
  The bare-root landing serve is Accept-gated: HTML requests get the
  landing tool (project picker), JSON requests fall through to
  ServeDirectory and get the generic listing.
- landing's fetchProjects filters the new generic shape (is_dir,
  strip trailing slash) — same pattern fetchParties already used at
  /<project>/archive/.

== Form editor retirement ==

`<dir>/.zddc.html` was a server-rendered form for editing per-directory
.zddc files (~900 LOC across zddceditor.go, zddchandler.go, zddc_assets.go).
Browse's YAML/CodeMirror editor (with .zddc-schema lint) already edits
the same files via the generic file-API. Two ways to edit the same data
is exception, not convention.

- Delete zddceditor.go, zddchandler.go, zddc_assets.go and tests.
- `/<dir>/.zddc.html` → 302 redirect to `/<dir>/?file=.zddc` (browse
  opens the .zddc in its editor pane).
- /.profile/zddc/* namespace deleted (REST API + assets sub-route).
- Profile page's "Editable .zddc files" list links to browse.
- ServeZddcFile's 405 message + virtual-body comment point at the
  browse URL instead of the dead form.

== Admin elevation (Principal model) ==

Sudo-style: admins are treated as normal users by default; opting into
admin powers is per-request and gated by a `zddc-elevate=1` cookie.

- zddc.Principal{Email, Elevated} replaces bare-email arguments on
  IsAdmin / IsSubtreeAdmin / CanEditZddc. The signature change makes
  the elevation gate compiler-enforced at every admin call site —
  audit-fragility is gone. The empty-email short-circuit is no longer
  load-bearing for elevation; Principal.gate() is the explicit check.
- handler.ACLMiddleware derives Elevated per request: bearer tokens
  are implicitly elevated (CLI clients can't toggle a cookie); browser
  sessions elevate only when zddc-elevate=1 is set. PrincipalFromContext(r)
  is the one-call-per-site bundling helper.
- Every admin-check call site updated to pass a Principal.
- /.auth/admin (forward_auth target for the dev-shell IDE) explicitly
  bypasses elevation with a synthetic-elevated Principal — different
  cookie scope than zddc-server origin, documented inline.
- AccessView gains CanElevate (elevation-independent "does this email
  have admin authority anywhere?") so the header toggle can render
  itself for an un-elevated admin who hasn't opted in yet.
- ServeProjectList is removed; ProjectInfo + EnumerateProjects stay
  for the profile page's server-rendered project list.
- MatchAppHTML stays — still used by main.go to route <dir>/<tool>.html
  URLs to the apps subsystem when no real file exists.
- Test helpers carry Elevated=true by default (matches the
  pre-elevation default; tests for the un-elevated gate use the
  explicit form).

Go tests pass across all 14 internal packages. Browse + every other
tool rebuilds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:15:07 -05:00
9aa587aac0 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>
2026-05-12 10:29:44 -05:00
54dff4dcd3 feat(zddc): standard roles (document_controller, project_team) + role union/reset
Answers "can roles reset as well as add?" — yes, both now.

Role membership UNIONS across the cascade:
  - A deeper .zddc that defines an inherited role again with one
    extra member ADDS that member (was: deepest definition shadowed
    the ancestor's entirely).
  - New `reset: true` on a role definition breaks the union — that
    level's members are authoritative, ancestor definitions above
    are excluded; descendants below still union on top. Use it to
    give a project its own team independent of a deployment-wide
    default.
  - lookupRoleMembers / RoleMembers reworked: walk deep→shallow,
    union members, stop at the first reset:true; finally fold in
    chain.Embedded.Roles as the baseline so a role declared only in
    defaults.zddc.yaml is "defined" (and a deployment's on-disk
    redefinition unions on top).

Admin checks are now role-aware:
  - IsSubtreeAdmin / CanEditZddc's strict-ancestor scan use
    MatchesPrincipal instead of MatchesPattern, so `admins:
    [document_controller]` resolves to the role's members. The
    strict-ancestor scan resolves roles only up to level i, so a
    role defined at the deepest level (= dirPath) never confers
    self-edit rights.

Two standard roles ship in defaults.zddc.yaml (empty members — a
fresh deployment grants nothing until they're populated):

  document_controller — files into the WORM zones. Gets:
    - rw at the project level (read + overwrite-existing; NOT c, so
      it can't make arbitrary folders)
    - rwc at archive/ (can create party subfolders)
    - subtree-admin at working/ and staging/ (full create + manage,
      including taking over a fenced per-user home) — scoped HERE,
      not at the project root, so the WORM constraint still binds
      it in archive/<party>/received|issued
    - listed in worm: on received/ and issued/ → write-once-create
      survives the WORM mask

  project_team — read-only across the project. The per-user
    working home's fenced auto-own .zddc (rwcda for the creator)
    wins via deepest-match, so "read-only except what I own" falls
    out of the cascade with no special rule. Inside received/issued
    their r is preserved (worm: doesn't strip read).

archive/<party>/ gains `auto_own: true` (UNFENCED) so whoever
creates a party subtree (normally the doc controller) owns it and
can set up that counterparty's .zddc afterward — without fencing,
project_team:r still cascades through to received/issued.

Tests: roles_test (union + reset), standardroles_test (the
doc-controller scoped-create matrix + project-team read-only-except-
owned), ensure_test updated for the new party-folder auto-own.
fileapi_test's WORM doc-controller test already uses worm: [role].
All Go + 248 Playwright tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:17:46 -05:00