think
16px
820px

Secure Folders — P1 implementation plan (foundations)

Spec (contract): docs/superpowers/specs/2026-08-07-secure-folders-design.md (a84c70d).
Branch: feat/securefolder (worktree .claude/worktrees/securefolder).
Scope: P1 only — folder owner parity + transfer, the two switches (members_only
visibility across list/search/Quick Look; folder step-up gate), create-dialog presets.
No crypto in this phase.


0. Two interpretations this plan commits to (flagged to Main)

Both are implementation choices the spec leaves implicit. They are foundational, so they
are stated here rather than discovered in code review:

  1. members_only cuts inherited ACCESS, not just listings. Implemented as an
    inheritance barrier in the ACL recompute — the exact mechanism inherit_access=false
    already uses (folderChainRefs, go/internal/dms/app/acl.go:136-151). Non-members get
    no folder_acl_read/document_acl_read rows at all, so every ACL-filtered surface
    (list, search, semantic, suggest, MCP) hides the folder and its documents with zero
    per-surface SQL changes
    , and direct navigation fails too. The alternative (hide from
    listings but honor inherited access when the URL is known) is security theater; the
    switch is named members only. Spec support: "hidden from non-members" (goal), Switch A
    inherit = "parent ACL decides" ⇒ members_only = parent ACL does not decide.
  2. require_step_up is positional and inherits DOWN the whole subtree: effective
    step-up for a folder = bool_or(require_step_up) over its ancestor chain to the
    root
    (not stopped by ACL barriers — protection is about where the thing sits, same
    doctrine as the classification gate). A subfolder of a secure folder is inside the
    secure folder; otherwise moving a doc one level down dodges the gate.

Consequences worth stating: content admins still see members_only folders (the ACL
predicates' bypass leg, isContentAdmin — consistent with the existing wildcard-admin
doctrine and with the spec's honesty that base switches don't defend against the
operator). Admins are NOT exempt from step-up (the document gate's doctrine: "is this
person actually here", handlers_stepup.go:30-35).


1. What exists (verified, with anchors)

  • ACL recompute: recomputeDocOne (app/acl.go:29) and recomputeFolderOne
    (acl.go:205) rebuild the flattened projections from content_acl;
    folderChainRefs (acl.go:136) stops climbing at the first inherit_access=false
    folder. recomputeSubtreeTx (acl.go:177) rebuilds folders + docs under a folder;
    called by every ACL edit / move / create. RecomputeAllFolderACL runs at boot
    (cmd/obscura-server/wire.go:529) — so a new is_owner column needs no projection
    backfill, only the folders.owner_id column backfill.
  • Owner precedent (documents): owner_id text NOT NULL DEFAULT '' (mig 00017),
    is_owner injection at acl.go:115 (owner floor Manage acl.go:85, deny-exempt
    acl.go:99-101), owner leg in EffectiveReadAccess (acl_pg.go:544), transfer at
    handlers_acl.go:214TransferOwnership (acl.go:828), UI
    DocumentDetailView.tsx:1064 + useTransferOwnership (api/documents.ts:562).
  • Folder creation records the creator's explicit Manage grant in content_acl
    (service.go:576-597: "this explicit Manage grant IS the ownership") — that grant is
    the derivable-owner source for the backfill.
  • Step-up: StepUpValid (15-min window, TOTP+passkey, auth/app/service.go:1189);
    the two middleware precedents requireStepUp (docID, DLP-driven, FAIL CLOSED) and
    requireLetterStepUp (letterID) in handlers_stepup.go. Error code
    auth.step_up_required; web prompt web/src/features/documents/StepUpPrompt.tsx
    with caller-supplied onVerified retry.
  • Filtering primitives: folderAclPredicate (adapters/pg.go:428) backs
    ListFolders/SearchFolders; aclPredicate (pg.go:381) backs ListDocuments,
    FTS search, advanced search, semantic search, embedding suggest, trash, expired,
    links, MCP list/search. EffectiveFolderAccess (acl_pg.go:564) backs
    requireFolderAccess. WritableFolderIDs (acl_pg.go:401) backs SuggestFolders.
  • Quick Look has no endpoint — it renders from already-fetched list/search rows
    (QuickLook.tsx:15-19), so it is covered entirely by the list/search filtering.
  • The oracle surfaces with NO ACL filter today (found in the survey; the ones that
    would defeat members_only): GET /folders/{id} (server.go:994 — used by the web
    breadcrumb/ancestry walker api/documents.ts:212-242), GET /folders/{id}/attributes
    (:1020), /view-settings (:1023), /retention (:962), /zip (:1029).
  • Audit: s.audit.Append(ctx, auditapp.Entry{Partition, Actor, Action, Resource, Payload}) (audit/app/ports.go:14-27); template call handlers_retention_sources.go:64.
  • Folder settings endpoints are per-field PUTs; SetFolderRetention
    (handlers_retention_sources.go:36) is the pattern for a new one.
  • API client is generated from api/openapi.yaml (pnpm gen:api; npm path broken —
    run openapi-typescript directly per dev-gotchas). Folder schemas at
    openapi.yaml:13060 (CreateFolderRequest), :14410 (Folder).

2. Tasks

T1 — Migration: folder owner + the two switches

New migration (number = ls go/migrations | tail at commit time; 00176 was last at
planning time, so nominally 00177):

  • ALTER TABLE folders ADD COLUMN owner_id text (nullable — spec §3).
  • ALTER TABLE folders ADD COLUMN visibility text NOT NULL DEFAULT 'inherit' CHECK (visibility IN ('inherit','members_only')).
  • ALTER TABLE folders ADD COLUMN require_step_up boolean NOT NULL DEFAULT false.
  • ALTER TABLE folder_acl_read ADD COLUMN is_owner boolean NOT NULL DEFAULT false
    (mig 00045/00101 explicitly omitted it because folders had no owner).
  • Backfill owner_id: the folder's creator where derivable = the single
    user-kind subject holding an explicit top-tier grant in content_acl
    (resource_type='folder', access_mode = 4 /Manage — the tier folder creation has
    always written). Exactly-one rule: two or more candidate users ⇒ NULL (admin assigns).
  • No folder_acl_read.is_owner backfill — boot RecomputeAllFolderACL writes it.
  • +goose Down for all of it.

T2 — Domain + recompute + effective-access plumbing (Go)

  • domain.Folder (domain/document.go:70): OwnerID string (scan
    COALESCE(owner_id,'')), Visibility string, RequireStepUp bool.
  • folderColumns + scanFolder + InsertFolder (adapters/pg.go:34-66) updated.
  • folderChainRefs (acl.go:136): stop the climb when !f.InheritAccess || f.Visibility == members_onlythis one line is the entire members_only filtering
    mechanism
    for both projections.
  • recomputeFolderOne (acl.go:205): owner injection mirroring recomputeDocOne
    — owner floor Manage, IsOwner on the row, owner exempt from denies (break-glass;
    the doc rationale at mig 00101 applies verbatim: without it a deny on a group the
    owner belongs to locks the owner out with no way back).
  • EffectiveFolderAccess (acl_pg.go:564) + WritableFolderIDs (acl_pg.go:401):
    add the owner leg (WHEN bool_or(r.is_owner) THEN … beats deny), symmetric with
    EffectiveReadAccess.
  • CreateFolder (service.go:554): set owner_id = creatorID on insert; accept
    visibility/require_step_up (validated); keep the explicit Manage grant (it is
    what inherits down to subfolders/docs — owner injection does not).

T3 — Owner transfer endpoint + UI

  • PUT /api/v1/folders/{id}/owner {owner_id} — modeled line-for-line on
    TransferDocumentOwnership (handlers_acl.go:214): guard
    folder.write + requireFolderAccess(AccessManage), then narrow to
    owner-or-content-admin; validate target via s.auth.GetUser.
  • Service TransferFolderOwnership: one txn — SetFolderOwnerupsert an explicit
    Manage grant for the new owner in content_acl
    (so ownership inherits down the
    subtree exactly like creation's grant does) → recomputeSubtreeTx. The old owner's
    explicit grant is left in place (removing their reach is an ACL edit, the admin's
    call). Same-owner no-op.
  • Explicit audit: Action: "dms.folder_owner_transferred", payload old/new owner.
  • OpenAPI + regenerated client; UI: owner row + transfer modal in
    EditFolderModal.tsx GeneralTab, gated owner-or-admin (mirror
    DocumentDetailView.tsx:1064 + canTransfer at :401).

T4 — members_only: settings endpoint + oracle closures

  • PUT /api/v1/folders/{id}/security {visibility, require_step_up} — template
    SetFolderRetention. Guard folder.write + requireFolderAccess(AccessManage).
    Service validates, updates, runs recomputeSubtreeTx when visibility changed,
    audits (dms.folder_security, payload both switches old→new).
  • Close the existence oracles with a shared handler-level guard
    guardFolderVisible(r, folderID): if the folder is members_only AND caller is not
    content-admin AND EffectiveFolderAccess < AccessRead (owner leg included) ⇒
    kernel.ErrNotFound (404, not 403 — existence hiding, the tenant-boundary
    doctrine). Mount in: GetFolder, GetFolderAttributes, GetFolderViewSettings,
    GetFolderRetention, DownloadFolderZip. Non-members_only folders keep today's
    open behavior (breadcrumbs over partially-granted trees keep working — spec:
    inherit = "today's behavior").
  • Web: fetchAncestry/fetchFolderPath (api/documents.ts:212-242) tolerate a 404
    mid-walk — truncate the breadcrumb instead of failing the page.
  • No changes needed in list/search/semantic/MCP SQL (the T2 barrier covers them) —
    but each is an explicit verification item in T7.

T5 — Folder step-up gate (NEW middleware) + unlock audit

  • Service: FolderStepUpRequired(ctx, folderID) — one recursive-CTE query,
    bool_or(require_step_up) over the ancestor chain to root;
    DocumentFolderStepUpRequired(ctx, docID) resolves doc→folder→same. Both FAIL
    CLOSED
    on lookup error (the document gate's doctrine, not the letter gate's —
    withholding on doubt is the feature here).
  • Middleware requireFolderStepUp (new, per spec — NOT a reuse of the docID one):
    resolves the folder from, in order: {id}/{folderID} route param; folder_id /
    parent_id query param (entry via listing); {docID} route param → document's
    folder. No folder in the request ⇒ no opinion (pass). Gated + no live
    StepUpValid ⇒ 403 auth.step_up_required (the code the web/mobile machinery
    already recognizes). Per-session-window by construction: it reads the same 15-min
    grant, so one proof opens every secure folder for the window (spec Switch B scope).
  • Mount points:
  • GET /folders/{id} (entry via direct nav) — after the T4 visibility guard.
  • GET /folders (gates only when parent_id names a gated chain — listing a secure
    folder's children IS entering it; the q= picker mode returns names only =
    metadata, stays open per the gates-CONTENT-not-metadata doctrine).
  • GET /documents (gates only when folder_id present and gated).
  • Document content routes — mounted beside the existing requireStepUp on the
    same groups (server.go:1055, 1080-1084, 1120-1126, 1150-1164) + /folders/{id}/zip:
    a search hit must not hand over bytes of a doc whose folder is gated.
  • Folder metadata (name in pickers, breadcrumb, search-hit titles) stays readable —
    same split the DLP gate draws (handlers_stepup.go:22-24).
  • Unlock audit: first successful gated entry per (user, folder) per window appends
    dms.folder_unlocked (Partition dms) — deduped by a process-local TTL map
    (15 min); a restart re-audits once, which is harmless over-reporting into an
    append-only chain, never under-reporting beyond it.
  • Known accepted gap for P1 (recorded, addressed in P2 leak-surface work): advanced
    search returns content_text snippets; the DLP step-up gate has the same shape
    today (it gates content routes, not search snippets), so P1 matches the existing
    gate surface exactly rather than inventing a stricter one for folders only.

T6 — Presets + web wiring

  • Create dialog: extend the create path (FolderNameModal.tsx +
    DocumentsPage.tsx:258-274) with presets (Carbon RadioTiles): Standard
    (inherit/none — default), Restricted (members_only), Secret
    (members_only + step_up), plus an "advanced" disclosure exposing the two raw
    switches. Rename keeps the old modal. CreateFolderRequest gains the two fields.
  • EditFolderModal: new "Security" block (AccessTab or a fourth tab): the two
    switches (Carbon Toggle — mind the hideLabel gotcha), with the AccessTab inherit
    toggle disabled + explained while members_only (the barrier supersedes it).
  • Badges: lock icon on secure folders in FolderStrip/tree rows (FolderRow
    gains the two fields; members see the badge, non-members don't see the folder).
  • Entry UX (gate-in-place, the letter/mobile pattern): DocumentsPage catches
    auth.step_up_required from the children/documents fetch for the current folder →
    renders StepUpPrompt in place → onVerified refetches. Same catch on
    fetchFolderDetail.
  • OpenAPI for all new/changed endpoints + regenerate client (direct
    openapi-typescript, npm wrapper broken); i18n en + id.

T7 — Verification (the definition of P1-done)

  1. go build ./... && go vet ./...never go test ./... (live-DB DSN).
  2. Migration smoke on a scratch pgvector/pgvector:pg17 in dind: goose up from
    zero; seed fixtures (folder with one Manage user → owner backfilled; folder with
    two → NULL; folder with none → NULL); assert the fixtures actually seeded
    before asserting outcomes (the vacuous-fixture trap from the control-migrations
    smoke).
  3. Deploy to demo (ssh valbox 'OBSCURA_ENV_FILE=…/deploy/mekari.env bash …/deploy/update.sh --yes'), confirm /api/v1/version == my commit, re-check
    ls go/migrations | tail for co-agent races at that moment.
  4. Playwright e2e with a NON-admin user (every demo user is admin; admins bypass
    both the ACL predicates and isContentAdmin — an admin-only e2e proves nothing).
    Create a non-admin via the admin API first. Scenarios:
    - members_only folder + doc inside: invisible to non-member in folder tree,
    folders?q= picker, documents list, FTS search, advanced search, semantic
    search; GET /folders/{id} and /attributes → 404; member sees all of it.
    - explicit doc-level grant inside a members_only folder still surfaces that doc to
    the grantee (explicitly granted = member of the doc — intended).
    - step_up folder: entry 403 → in-place prompt → TOTP → contents render; doc
    preview inside is gated; second secure folder within 15 min opens WITHOUT a new
    prompt (session-window semantics); after window expiry the gate re-arms.
    - owner: backfilled owner visible; transfer (admin) moves it; owner still reads
    the folder after a deny on a group they belong to (break-glass).
    - presets: "Secret" preset creates a folder with both switches set.
    - breadcrumb truncates (no crash) for a member of a subfolder whose ancestor is
    members_only to them.
  5. Reset the test users' TOTP state after e2e (profile-redesign memory).

3. Sequencing & landing

T1 → T2 (one commit: migration + plumbing must land together for boot recompute) →
T3, T4, T5 (independent, separate commits) → T6 → T7 (deploy + e2e) — all on
feat/securefolder, explicit-path staging only, git show --stat HEAD after each.
Merge to main only after T7 passes. Then report to Main before any P2 work (the
crypto phases get their own review gate).

4. Risks / notes

  • recomputeSubtreeTx is O(docs) per visibility flip — same cost class as an ACL
    edit on a big folder today; acceptable, noted.
  • Flipping members_only off re-exposes inherited access by design (the switch
    is Manage-gated + audited; the protection-floor ratchet doctrine applies to
    documents leaving secure folders, which is P2's move-out task, not to the switch).
  • Owner injection does NOT inherit down (only the explicit content_acl grant does)
    — that is why creation and transfer both write the grant, and it matches documents.
  • everyone subject grants on a members_only folder itself would defeat the point at
    the UI level; not blocked server-side in P1 (an explicit grant is an explicit
    grant), but the presets never do it. Flag to owner if it should be refused outright.
  • Mobile parity (folder badges, gate-in-place on folder entry) is NOT in P1 — the
    Mobile conversation owns it; the API shape here (reused auth.step_up_required
    code) is exactly what its letter/step-up port already handles.