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:
members_onlycuts inherited ACCESS, not just listings. Implemented as an
inheritance barrier in the ACL recompute — the exact mechanisminherit_access=false
already uses (folderChainRefs,go/internal/dms/app/acl.go:136-151). Non-members get
nofolder_acl_read/document_acl_readrows 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.require_step_upis 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) andrecomputeFolderOne
(acl.go:205) rebuild the flattened projections fromcontent_acl;
folderChainRefs(acl.go:136) stops climbing at the firstinherit_access=false
folder.recomputeSubtreeTx(acl.go:177) rebuilds folders + docs under a folder;
called by every ACL edit / move / create.RecomputeAllFolderACLruns at boot
(cmd/obscura-server/wire.go:529) — so a newis_ownercolumn needs no projection
backfill, only thefolders.owner_idcolumn backfill. - Owner precedent (documents):
owner_id text NOT NULL DEFAULT ''(mig 00017),
is_ownerinjection atacl.go:115(owner floor Manageacl.go:85, deny-exempt
acl.go:99-101), owner leg inEffectiveReadAccess(acl_pg.go:544), transfer at
handlers_acl.go:214→TransferOwnership(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 precedentsrequireStepUp(docID, DLP-driven, FAIL CLOSED) and
requireLetterStepUp(letterID) inhandlers_stepup.go. Error code
auth.step_up_required; web promptweb/src/features/documents/StepUpPrompt.tsx
with caller-suppliedonVerifiedretry. - 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 defeatmembers_only):GET /folders/{id}(server.go:994— used by the web
breadcrumb/ancestry walkerapi/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 callhandlers_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 —
runopenapi-typescriptdirectly 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 incontent_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_ownerbackfill — bootRecomputeAllFolderACLwrites it. +goose Downfor 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_only— this one line is the entire members_only filtering
mechanism for both projections.recomputeFolderOne(acl.go:205): owner injection mirroringrecomputeDocOne
— owner floor Manage,IsOwneron 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): setowner_id = creatorIDon 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 vias.auth.GetUser.- Service
TransferFolderOwnership: one txn —SetFolderOwner→ upsert an explicit
Manage grant for the new owner incontent_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.tsxGeneralTab, gated owner-or-admin (mirror
DocumentDetailView.tsx:1064+canTransferat:401).
T4 — members_only: settings endpoint + oracle closures
PUT /api/v1/folders/{id}/security{visibility, require_step_up}— template
SetFolderRetention. Guardfolder.write+requireFolderAccess(AccessManage).
Service validates, updates, runsrecomputeSubtreeTxwhen 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 ismembers_onlyAND caller is not
content-admin ANDEffectiveFolderAccess < 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_idquery param (entry via listing);{docID}route param → document's
folder. No folder in the request ⇒ no opinion (pass). Gated + no live
StepUpValid⇒ 403auth.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 whenparent_idnames a gated chain — listing a secure
folder's children IS entering it; theq=picker mode returns names only =
metadata, stays open per the gates-CONTENT-not-metadata doctrine).GET /documents(gates only whenfolder_idpresent and gated).- Document content routes — mounted beside the existing
requireStepUpon 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(Partitiondms) — 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 returnscontent_textsnippets; 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.CreateFolderRequestgains the two fields. - EditFolderModal: new "Security" block (AccessTab or a fourth tab): the two
switches (Carbon Toggle — mind thehideLabelgotcha), with the AccessTab inherit
toggle disabled + explained whilemembers_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_requiredfrom the children/documents fetch for the current folder →
rendersStepUpPromptin place →onVerifiedrefetches. Same catch on
fetchFolderDetail. - OpenAPI for all new/changed endpoints + regenerate client (direct
openapi-typescript, npm wrapper broken); i18nen+id.
T7 — Verification (the definition of P1-done)
go build ./... && go vet ./...— nevergo test ./...(live-DB DSN).- Migration smoke on a scratch
pgvector/pgvector:pg17in 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). - 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 | tailfor co-agent races at that moment. - Playwright e2e with a NON-admin user (every demo user is admin; admins bypass
both the ACL predicates andisContentAdmin— 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. - 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
recomputeSubtreeTxis O(docs) per visibility flip — same cost class as an ACL
edit on a big folder today; acceptable, noted.- Flipping
members_onlyoff 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_aclgrant does)
— that is why creation and transfer both write the grant, and it matches documents. everyonesubject 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 (reusedauth.step_up_required
code) is exactly what its letter/step-up port already handles.