| Membership shape | exactly 1 user-owner row | exactly 1 user-owner row | 0..N users + 0..N groups at any role |
| `add_member` | refused | refused | allowed |
| `remove_member` | refused (sole owner is fixed) | refused (sole owner is fixed) | allowed except sole owner |
| Rename | allowed (by the owner) | allowed (by the owner) | allowed (by any owner) |
| Delete via API | **refused** — deleting this loses all the user's files; the only path is user-delete cascade | allowed (it's just a silo) | allowed (by an owner; CASCADEs the drive's contents) |
| Default-drive lookup result | this drive | never | never |
| On user-delete | `ON DELETE CASCADE` via `default_for_user` FK (free) | application-layer cleanup: enumerate via `role_grants` (`subject_id=<user> AND resource_type='drive' AND role='owner'`) and delete | role_grants rows referencing the user are dropped; refuse user-delete if any shared drive would lose its last owner |
| Kind conversion | no — always default-personal | yes → may be promoted to `kind='shared'` later (drops the single-user restriction, picks up members) | no |
| New internal user registers | Auto-create a default personal drive (`kind='personal'`, `default_for_user=<new_user>`, `quota_bytes=<OXICLOUD_DEFAULT_QUOTA_BYTES>`) + its root folder (`name='Personal'`, `parent_id=NULL`, drive_id pinned) + the Owner role_grant (`role_grants(subject_type='user', subject_id=<user>, resource_type='drive', resource_id=<drive>, role='owner')`) — **all four writes in one CTE statement** (§3), atomic against server crash. |
| User deleted | **Default** personal drive cascade-deletes via `ON DELETE CASCADE` on `default_for_user`. **Secondary** personal drives (`kind='personal' AND default_for_user IS NULL` and whose sole owner `role_grants` row points at the user) are deleted by an application-layer pass in the same transaction. `role_grants` rows referencing the deleted user are removed from all shared drives. If a removal would leave a shared drive with zero owners, deletion is refused — admin must transfer first. |
| Group deleted | Refuse if the group is a member of any shared drive that would lose its last owner. Admin must transfer or remove the group's role from those drives first. (Groups can't be members of personal drives.) |
| Add member to personal drive | Refuse. Personal drives are single-user — collaborate via per-resource grants or by moving content into a shared drive. |
| Remove sole owner of personal drive | Refuse. The only deletion path for a personal drive is user-deletion via cascade. |
| Delete personal drive | Refuse from the API. Only ON DELETE CASCADE (user deletion) drops it. |
| Rename personal or shared drive | Allowed for any owner-role caller. The drive's display name lives on its root folder (§3) — rename via `PATCH /api/folders/<root_folder_id>`, not a drive-specific endpoint. |
| Remove last owner of shared drive | Refuse — drive must always have ≥1 owner. App-layer check on `DELETE FROM role_grants WHERE resource_type='drive' AND resource_id=$drive AND role='owner'`. |
| `/` (internal user) | Redirect to `/drive/<root-folder-id>` of the caller's default personal drive |
| `/` (external user) | Redirect to `/sharedwithme` (no personal drive exists) |
| `/drive/<folder-id>` | Folder view (root or descendant — drive context is recovered server-side from `folders.drive_id`) |
| `/config/drive/<drive-uuid>` | Drive configuration surface (members, policies, quota). Page is permission-aware: owner sees member management; editor/viewer see a read-only "Drive info" view |
| `/config/user/<user-uuid>` | (Future) User configuration — same shape so the `/config/<resource-type>/<uuid>` pattern is consistent across resources |
**Why folder-id, not drive-uuid + folder-id**: every `storage.folders` row carries `drive_id` after D0, so a single folder UUID recovers the drive context in one cheap lookup. Stable across cross-drive moves (D6): bookmarks keep working when a folder hops drives, because the folder UUID doesn't change.
**Why `/config/` is a separate top-level segment**, not `/drive/<uuid>/settings`: the URL prefix encodes intent ("we are configuring something"), not just resource location. Future configuration surfaces (`/config/user/<id>`, `/config/group/<id>`, `/config/share/<id>`) compose cleanly under the same prefix. It also avoids the singular-vs-plural ambiguity (`/drive/<X>` vs `/drives/<X>/settings`) that's easy to typo and hard to grep for.
> **The path-segment `/drives/<uuid>/` form is NOT used on the NC
> surface.** It is reserved for the native WebDAV surface (see
> table above). The NC surface keeps the URL shape
> `/remote.php/dav/files/<username>/<path>` and carries the drive
> selector in the **credential**, not the URL.
>
> NC desktop / mobile clients store credentials per
> `(host, username)` and offer a single sync root per saved
> account. A path-segment scheme would require NC clients to grow
> multi-root awareness, which they don't have. Two
> credential-side mechanisms are valid here:
>
> 1. **Username discriminator (`{user}~{drive-uuid}`)** — the
> chroot POC on `feat/nextcloud-drive` (commit `137169b7`).
> The Basic Auth username carries the drive UUID after a `~`
> separator; the URL stays under `/remote.php/dav/files/{user}~{uuid}/<path>`.
> Explicit on the wire, no per-credential server state needed
> beyond the existing app-password row.
>
> 2. **App-password ↔ drive binding** — store the chosen drive
> UUID directly on the `auth.app_passwords` row at issuance
> time. The Basic Auth username stays as `{user}` (clean
> NextCloud UX, no `~` to explain). The auth middleware looks
> up the app-password row, reads its `drive_id` binding, and
> uses that as the drive context. Each drive a user wants to
> sync gets its own app-password.
>
> Both are workable; option (2) is the cleaner UX (username
> matches what users type, no extra character to explain) but
> requires a schema add on `auth.app_passwords` and one extra
> JOIN in the hot auth path. Option (1) is the smallest possible
> change but exposes the `~` to the user. They're not mutually
> exclusive — the issuance flow can produce credentials in either
> shape. Decide before D1 ships which is the **default** the
> Login Flow v2 picker produces.
| URL | Resolves to |
|---|---|
| `/remote.php/dav/files/<username>/<path>` | That user's personal drive — unchanged. (App-password drive-binding NULL ⇒ personal.) |
| `/remote.php/dav/files/<username>~<drive-uuid>/<path>` | Option 1: explicit drive in the URL. |
| `/remote.php/dav/files/<username>/<path>` *(with `auth.app_passwords.drive_id` set)* | Option 2: drive resolved from the credential row. URL is indistinguishable from the unchanged personal case to the client. |
In either case, the auth middleware asserts the caller is a member
of the resolved drive before serving any DAV verb. Pre-existing NC
clients pointed at `/remote.php/dav/files/<username>/` continue
syncing the user's personal drive without reconfiguration —
regardless of which option is chosen as the default.
#### Username/UUID collision — defused
The `/remote.php/dav/files/<x>/` vs `/remote.php/dav/drives/<x>/`
split solves the worry about username/UUID ambiguity. The
discriminator is the literal segment (`files` vs `drives`), never
the value of `<x>`. A user happening to have a UUID-shaped username
(soft-delete in place), and `storage.trash_items` is a VIEW that
UNIONs them. The listing endpoint (`GET /api/trash/resources`)
filters by `user_id = caller`.
Post-drives, trash becomes **per-drive**:
- **Storage shape is unchanged.** The `drive_id` column added to
`storage.files` / `storage.folders` in Phase A already
identifies which drive a trashed row belongs to. No new
trash table, no schema work beyond updating the
`storage.trash_items` VIEW to surface `drive_id` alongside
(or replacing) `user_id`.
- **Trash listing query** filters by drive(s) the caller can
read. Default listing returns trash from every drive the
caller has membership on; a `?drive_id=<uuid>` parameter
scopes to one drive. UI shows a drive picker above the trash
list, same as the main file view.
- **Trash mutations are owner-only.** Per the §4 role-bundle,
`Delete` is in the owner bundle only — so today's "anyone
who can delete the original can act on its trash entry" is
already drive-owner-scoped. Specifically:
- **Send to trash** — any drive owner (carries `Delete`).
Personal drive: the user themselves.
- **Restore** — any drive owner. Operation reverses
`is_trashed`, sets `parent_id` back to `original_parent_id`
when that ancestor is still in the same drive (otherwise to
the drive root with a name conflict resolver).
- **Permanent delete** — any drive owner. Clears the row and
decrements drive `used_bytes`.
- **View trash** — any drive member (viewer / editor /
owner). Viewers can see what was deleted from a drive they
have access to; only owners can act on it. Mirrors Google
Drive's per-shared-drive trash UX.
- **Cross-drive moves carry their trash home with them**:
when a file moves from drive A → drive B and is later
trashed, the row's `drive_id` is B's, so trash for B sees
it (not A's, which is the natural and expected answer).
- **Cascade on drive deletion**: when a shared drive is
deleted (D3 will land the delete-drive flow), every
`storage.files` / `storage.folders` row with that
`drive_id` cascades, trashed or not. There's no need to
"drain the trash first" — the whole drive disappears in
one CASCADE.
- **Personal-drive trash** follows the same model: bound to
the personal drive, sole owner (= the user) does
everything. No new UX divergence between personal and
shared.
The orphan/aborted-upload sweep introduced in v0.7.0
(`944c8337`, periodic trash job) must become drive-aware so
it doesn't accidentally sweep across drives the caller
shouldn't see. It already keys off ownership; the rewrite is
a per-drive pass instead of per-user.
### 13. Upload paths and quota timing
Four upload protocols, each with different "when do we know the
size" and "when do we know the destination" answers. The Drive
migration pivots quota from user-scoped to drive-scoped without
changing protocol shapes — but one path (NC chunked) carries a
pre-existing wart that the chroot POC's `~` username (or the
`app_passwords.drive_id` binding) lets us finally fix.
| Protocol | Size known | Quota check fires | Destination / drive known |
|---|---|---|---|
| Default multipart (`POST /api/files/upload`) | At request start (Content-Length / multipart `size`). | `file_upload_service.rs:185` — `check_storage_quota(caller_id, metadata.size)` before any bytes are stored. | At request start (form field `folder_id`). Drive derives from `folder.drive_id`. |
| Native chunked (`POST /api/uploads`) | At session create — client declares `total_size` in JSON. | `chunked_upload_handler.rs:213` — at session creation against declared `total_size`. | At session creation (`folder_id` in JSON). Drive derives from `folder.drive_id`. |
| **NextCloud chunked** (`/remote.php/dav/uploads/{user}/{session}/...`) | Never declared. MKCOL creates empty session, PUT chunks arrive one at a time, client decides "done". | **Today: only at the final MOVE (assemble)** — `handle_assemble` → `file_upload_service::ingest_stream_to_cas` → quota check on the assembled size. Wasted-bandwidth wart: a client over quota can upload GB before the server can refuse. | **Today**: only at MOVE (parsed from the `Destination:` header). **With the chroot POC** (`{user}~{drive-uuid}` username, see §9): known at MKCOL — the auth middleware already split the username. **With `app_passwords.drive_id` binding**: known at MKCOL — the credential row pins the drive. |
| Delta protocol (`/api/files/delta/*`) | At `negotiate` (client provides manifest with `total_size`). | `delta_upload_service.rs:331` — at commit against `total_size`. | At negotiate (target file_id or `folder_id`). Drive derives accordingly. |
#### Decision — per-chunk incremental quota check on the NC chunked path
The three non-NC paths trivially pivot to drive-scoped quota:
replace `check_storage_quota(caller_id, size)` with
`check_drive_quota(drive_id, size)`. Destination is known at
handler entry; drive falls out of the destination's `drive_id`
column. No protocol change.
For NC chunked, the Drive migration **also closes the
wasted-bandwidth wart** because the drive identity is now known
at MKCOL (via `~` username or app-password binding — either NC
credential-side scheme from §9 surfaces it). Approach:
1. **MKCOL guard** — if `drive.used_bytes >= drive.quota_bytes`,
refuse the session creation with `507 Insufficient Storage`.
No point letting the client even start.
2. **Per-chunk PUT check** — track cumulative bytes received in
the session (sum of on-disk chunk sizes, maintained by the
chunked-uploads service). On each PUT, before writing the
chunk:
```text
if drive.used_bytes + session.bytes_so_far + chunk.size > drive.quota_bytes:
refuse with 507 Insufficient Storage
```
The first chunk that would push us over is refused; client
sees the error within one chunk's worth of wasted upload
(typically a few MB) instead of after the whole multi-GB file.
3. **Assemble-time re-check** stays as a defence-in-depth (in
case two concurrent sessions on the same drive each got past
the per-chunk check but their sum exceeds quota at MOVE). This
matches today's structure.
4. **Unlimited quota** (`quota_bytes IS NULL`) short-circuits all
three checks — no work.
The per-chunk check is O(1) amortised: each session tracks its
cumulative size as it goes. The drive's `used_bytes` is read from
the row once per chunk; with the v0.7.0 incremental-update
pattern (`b5b80549`, `d6987329`) that's a single primary-key
lookup, not an aggregate query.
Net effect on NC clients: nothing changes for in-quota uploads;
over-quota clients get a clear 507 within seconds instead of
after the whole upload finishes.
#### Editor-role delete and trash — call out the UX tension
§4's role bundle gives `Delete` only to `owner`. That means
in a shared drive, an editor who uploads a typo file CANNOT
send it to trash themselves — they have to ask an owner. This
is already flagged as Open Question 4 (revisit before D2) and
the answer there determines the trash UX for editors too. If
editors get a `Delete` capability (or a dedicated "trash own
content" capability), the trash mutation rules become "trash
your own files" + "drive owners can act on anyone's trashed
files". Until that's decided, the conservative answer above
| **Photos** (`/api/photos`) | Default Personal Drive only | `policies.include_in_photo_index = true` to opt a non-default drive in | Shared drives often carry images that aren't "photos" (screenshots, scans, charts-as-PNGs). Defaulting cross-drive pollutes the personal timeline. Opt-in for shared drives where the owner explicitly wants them indexed (e.g. "Family Photos" shared drive). |
| **Music** — library view (future) + playlists | Cross-drive (all accessible drives) | `policies.forbid_music_index = true` to opt a drive out | Audio files in shared drives are almost always intentional content (band collaboration, family music, podcast archive). Defaulting cross-drive matches user intent. Owner opts a drive out for the rare case it shouldn't be indexed. The Music section today is *only* playlists; a `/api/music/tracks` library view added later inherits this scope. |
| **Music playlists** (`audio.playlists`) | User-scoped, cross-drive curation | n/a | Playlists are a curation tool. `owner_id` stays on `auth.users(id)`; tracks reference files via `playlist_items.file_id` and may live in any drive the user has access to. At list time, `list_playlist_tracks` filters out tracks in drives the caller can no longer reach (see §11's defense-in-depth pattern). |
| **Favorites** (`/api/favorites/resources`) | Cross-drive (all accessible drives) | n/a | Personal organisation tool. Star a PDF from the work drive AND a photo from Personal — the whole point is cross-drive curation. ReBAC visibility check at list time drops rows the user can no longer reach. |
| **Recent items** (`/api/recent/*`) | Cross-drive (all accessible drives) | n/a | Personal history. Same shape as Favorites — you touched files across drives; the timeline reflects that. ReBAC visibility check at list time. |
| **Search** (`/api/search`) | Cross-drive (all accessible drives) | n/a | Discovery tool. See §11 for the Must-clause filter + handler-side ReBAC re-verification + anti-enum response shape. |
| **Trash** (`/api/trash/resources`) | Per-drive (owner-actioned) | n/a | Already specified in §12 — trash listing filters by drive(s) the caller can read; mutations require the owner role on the drive. |
#### Capability flag mechanism
Both `policies.include_in_photo_index` and
`policies.forbid_music_index` live under the same JSONB
`policies` column on `storage.drives` (see §8) — no new schema.
The default values reflect the table above: omitted = "off" for
photos (so non-default drives don't show photos unless the owner
opts in), omitted = "off" for music (so all accessible drives
*are* indexed unless the owner opts out).
The owner-only UI in the drive settings panel toggles these
flags. The query layer reads them at request time; flipping
either flag is instant — no reindex required because the filter
applies in the query Must-clause, the index itself is unchanged.
#### The Photos/Music asymmetry — defensible, not a smell
Photos defaulting to "default-drive only" while Music defaults to
"cross-drive" is the one case where two similar surfaces have
different defaults. The justification is the noise-risk argument
above: image content in shared drives is heterogeneous (often
not "photos" in the gallery sense), audio content in shared
drives is usually intentional. The capability flags let owners
fix either case, but the defaults match what the typical user
will want without configuration.
If a uniform rule is ever preferred, the cheapest move is to
flip Photos to cross-drive with `forbid_photo_index` as the
opt-out (mirroring Music). That can land later without a schema
change — just a behaviour change.
#### Verification sketch
The D0 Hurl suite (`tests/api/drives_foundation.hurl`) covers
the scope decisions concretely:
- Photos: file uploaded in Personal appears in `/api/photos`; same
file uploaded into a secondary personal drive does NOT appear
unless `include_in_photo_index` is set on that drive.
- Music: track uploaded in any accessible drive appears in the
library / sweeper output; setting `forbid_music_index` on a
drive removes its tracks from the next library response.
- Favorites: star a file in drive A and a file in drive B (both
accessible to caller); list returns both. Lose access to drive
B → next list omits the B file (no error, just absent).
- Refuse if any user has `storage_used_bytes > storage_quota_bytes`
by an amount that wouldn't fit the destination drive's quota
semantics (sanity check).
### Phase B — cleanup (PR D7, one release later)
1. Drop `user_id` from `storage.folders` and `storage.files`.
2. Drop dual-write code paths.
3. Deprecate `auth.users.storage_quota_bytes` (or drop — quotas live
on drives now).
Phase B is the point of no return; deferring it by one release gives
us a real rollback window while the new model bakes in production.
## PR sequencing
| PR | Scope | Risk |
|---|---|---|
| **D-Prep — role_grants refactor** | `access_grants → role_grants` schema migration with role-bundle semantics. `Manage` Permission added to the enum + role bundle. Engine reads role_grants only; `access_grants` removed (after one dual-write release if compat is needed). API gains `role` parameter on grant endpoints; audit log emits one `role_grant.*` event per role assignment instead of N permission events. **No Drive concept yet.** Sets the foundation that all subsequent PRs build on. **Data shape confirmed**: empirical audit shows >99% of existing `access_grants` rows already cluster into the standard bundles (viewer/editor/owner) — the migration is mechanical for the vast majority of data; the <1% edge cases get absorbed by shipping `commenter` and `contributor` roles on day one or get an explicit per-row migration decision logged. | **Medium** — touches the load-bearing authorisation table, but the data shape removes the main migration risk |
| **D0 — foundation** | `storage.drives` schema (no `drive_members` — uses `role_grants` from D-Prep); `Drive` domain entity; migration creating personal drives + backfilling `drive_id` on every resource; read-only `GET /api/drives` listing the caller's drives (single query: `SELECT … FROM role_grants WHERE subject_id=$caller AND resource_type='drive'`). Dual-write `user_id` alongside `drive_id` for safety. **No new UI.** **Every upload path stamps `drive_id` at insert**: classic multipart (`file_handler::upload`), chunked NC (`uploads_handler`), streaming CDC (`upload_ingest`), delta upload (`delta_upload_service`), instant upload by hash. Tantivy reindex (see §11) is part of this PR. **Provenance columns added** (see §14): `created_by` and `updated_by` on both `storage.folders` and `storage.files`, FK to `auth.users` with `ON DELETE SET NULL`; backfilled from `user_id` so pre-Drive content has provenance from day one; every mutation path that touches `updated_at` also sets `updated_by`. | **High** — every storage query touches, all upload paths touched |
| **D1 — UI switcher + URL routing** | Sidebar drive picker, `/drive/<folder-id>` frontend route (drive context recovered server-side from `folders.drive_id`), `/config/drive/<drive-uuid>` for drive admin. `/` redirects to `/drive/<root-folder-id>` of the caller's default personal drive (internal users) or `/sharedwithme` (external users with no personal drive). WebDAV path dispatcher recognising `drives/<uuid>` as the drive-explicit prefix on `/webdav/` (NC keeps the credential-side scheme — see §9). | Medium |
| **D2 — drive membership API + per-drive trash auth** | `POST /api/drives/{id}/members`, `DELETE`, `PUT` for role changes — thin handlers that translate to `role_grants` INSERT/DELETE/UPDATE with `resource_type='drive'`. `Resource::Drive(Uuid)` (added in D-Prep at the enum level) gets its specialised handler surface here. Shared-drive last-owner protection. Group-as-subject support reuses the existing `subject_groups` machinery. **Personal-drive guards** (`add_member`, `remove_member`, `delete_drive` refuse on `kind='personal'` — see §2). **Per-drive trash authorisation** (§12): trash listing filters by drive(s) the caller can read; trash mutations (send/restore/permanent-delete) require `role='owner'` on the drive; `storage.trash_items` VIEW updated to surface `drive_id`; orphan/aborted-upload sweep becomes per-drive. | Medium |
| **D3 — group-owned shared drives** | "Create shared drive" flow — admin or group owner triggers, drive created with `kind='shared'`, initial owner row is the group. Group-deletion guard refuses if the group is the last owner of any drive. Drive-rename, drive-delete. | Low |
| **D4 — per-drive quota** | Move storage accounting off `auth.users.storage_used_bytes` onto `storage.drives.used_bytes`. **Re-point the existing per-user incremental CTE** (introduced in v0.7.0 — see `b5b80549`, `d6987329`) at drive rows; don't reinvent the counting logic. Upload paths check `drive.quota_bytes` instead of (or in addition to) the user's quota for the dual-write window. **Per-chunk incremental quota check on the NC chunked path** (see §13): MKCOL refuses when the drive is already over quota; each PUT chunk runs an O(1) `used + session_so_far + chunk_size > quota` test and refuses with 507 within one chunk of wasted upload. Closes a pre-existing wart where NC clients could upload GB before learning they were over quota. Reconciliation job runs once per day to fix drift. | Medium |
| **D5 — policies** | JSONB policies column + enforcement at the four known callsites. Owner-only UI in drive settings. Ship policies one at a time if you want fine-grained rollout — `forbid_public_links` first (lowest risk), then `forbid_external_sharing`, then `forbid_sharing`, then `forbid_cross_drive_move`. | Low |
| **D6 — cross-drive move + audit** | Move folder/file between drives (allowed by default; gated by `forbid_cross_drive_move` policy on the source drive). Audit events for every drive lifecycle event (`drive.created`, `drive.member_added`, `drive.member_removed`, `drive.policy_changed`, `drive.deleted`, `resource.moved_between_drives`). | Low |
| **D7 — back-compat sweep** | Drop `user_id` from `storage.folders` / `storage.files`. Drop dual-write code. Drop or deprecate `auth.users.storage_quota_bytes`. **Provenance columns (`created_by`, `updated_by`) stay** — they were populated from D0 and are now the sole source of authorship signal. | Low — but the point of no return |
Approximate total: 4–6 weeks of focused work, depending on test
coverage depth.
## Out of scope for v1 (worth noting so we don't accidentally invite scope creep)
- **Timeboxed session policy** — drives that auto-lock after N
minutes. Big middleware lift.
- **End-to-end encryption policy** — client-side encryption with