File-browser: make Owner column, per-file lock, and folder item-counts real (deferred from #311) #315

Closed
opened 2026-08-26 15:04:26 +00:00 by Cordy · 4 comments
Owner

Deferred from the file-browser redesign (#311, PR2 internal/api/listmeta.go). The mockup depicts three pieces of per-item data that do not exist in the current model, so PR2 shipped only the two signals that are truthfully sourceable (shared, hold) and left these out rather than fabricate them. This issue is the placeholder to deep-dive later.

What's missing and why

1. Owner (the Owner column + avatar)
storage.FileInfo (internal/storage/storage.go) is Name / Path / Size / ModTime / IsDir / ContentType / ETag — there is no uploader/creator field, and by design the storage backend is the single source of truth with no DB-backed file index. So "who owns this file" is simply not recorded anywhere today. In a personal home the answer is trivially the viewer (which is why the column is hidden there); in a shared space it's genuinely unknown.

  • The Owner column is currently kept hidden via the intentional document.body.setAttribute("data-space","personal") default in web/static/index.html. That default should come out as part of this work, once owner data exists, so data-space reflects the real context (personal/space/shares) again.
  • Design question: do we introduce per-file creator metadata (a sidecar/index, or backend object metadata where supported), and how does that survive the "no DB" and S3/Garage constraints? What's the story for files created out-of-band (WebDAV, direct backend)?

2. Per-file lock (the lock chip)
There is no per-file lock concept in Cairn. The only lock is internal/statestore/lock.go — an instance boot-guard that stops two instances sharing one backend. The mockup's lock chip has nothing to bind to. Would need a real per-path lock subsystem (WebDAV LOCK? advisory app-level locks?) with a decision on semantics and enforcement.

3. Folder item-counts (the "12 items" name sub-line)
Requires counting each subdirectory's children. On the main file-listing path that's a List per folder = N+1 backend round-trips on every navigation — a real latency/load regression on an object store. Needs either a cheap count source, a bounded/opt-in fetch, or a cached index before it's safe to turn on.

Not blocking

The redesign is complete and live without these; shared and hold chips already ship. This is a "when we want to invest" feature, not a bug. Owner is the highest-value of the three and the one that would also let us retire the data-space="personal" default.

Refs: #311 (PR2/PR3), internal/api/listmeta.go, internal/storage/storage.go, internal/statestore/lock.go.

Deferred from the file-browser redesign (#311, PR2 `internal/api/listmeta.go`). The mockup depicts three pieces of per-item data that **do not exist in the current model**, so PR2 shipped only the two signals that are truthfully sourceable (`shared`, `hold`) and left these out rather than fabricate them. This issue is the placeholder to deep-dive later. ## What's missing and why **1. Owner (the Owner column + avatar)** `storage.FileInfo` (`internal/storage/storage.go`) is `Name / Path / Size / ModTime / IsDir / ContentType / ETag` — there is **no uploader/creator field**, and by design the storage backend is the single source of truth with no DB-backed file index. So "who owns this file" is simply not recorded anywhere today. In a personal home the answer is trivially the viewer (which is why the column is hidden there); in a shared space it's genuinely unknown. - The Owner column is currently kept hidden via the intentional `document.body.setAttribute("data-space","personal")` default in `web/static/index.html`. That default should come out **as part of this work**, once owner data exists, so `data-space` reflects the real context (personal/space/shares) again. - Design question: do we introduce per-file creator metadata (a sidecar/index, or backend object metadata where supported), and how does that survive the "no DB" and S3/Garage constraints? What's the story for files created out-of-band (WebDAV, direct backend)? **2. Per-file lock (the `lock` chip)** There is no per-file lock concept in Cairn. The only lock is `internal/statestore/lock.go` — an **instance boot-guard** that stops two instances sharing one backend. The mockup's `lock` chip has nothing to bind to. Would need a real per-path lock subsystem (WebDAV `LOCK`? advisory app-level locks?) with a decision on semantics and enforcement. **3. Folder item-counts (the "12 items" name sub-line)** Requires counting each subdirectory's children. On the main file-listing path that's a `List` per folder = **N+1 backend round-trips** on every navigation — a real latency/load regression on an object store. Needs either a cheap count source, a bounded/opt-in fetch, or a cached index before it's safe to turn on. ## Not blocking The redesign is complete and live without these; `shared` and `hold` chips already ship. This is a "when we want to invest" feature, not a bug. Owner is the highest-value of the three and the one that would also let us retire the `data-space="personal"` default. Refs: #311 (PR2/PR3), `internal/api/listmeta.go`, `internal/storage/storage.go`, `internal/statestore/lock.go`.
Author
Owner

Status check after the v0.6.8x–v0.6.101 UI epic:

3. Folder item-counts — DONE (shipped in the file-browser fidelity round, internal/api/listcounts.go). fillItemCounts sets an items child count on directory entries, one extra List per subfolder, capped at 100 counted folders per listing so a huge directory cannot turn one browse into a listing storm; entries past the cap keep no count and render as the plain kind. Covered by listcounts_test.go.

1. Owner column — NOT done. storage.FileInfo still carries no uploader/creator (listmeta.go still deliberately omits it), and the data-space="personal" default that hides the Owner column is still in place. The open design question stands: where creator metadata lives given no-DB + S3/Garage, and what happens for files created out-of-band (WebDAV, direct backend).

2. Per-file lock — NOT done. Still no per-path lock subsystem; the only lock remains the instance boot-guard in internal/statestore/lock.go.

Remaining scope of this issue = owner metadata (and retiring the data-space default with it) + the per-file lock decision.

Status check after the v0.6.8x–v0.6.101 UI epic: **3. Folder item-counts — DONE** (shipped in the file-browser fidelity round, `internal/api/listcounts.go`). `fillItemCounts` sets an `items` child count on directory entries, one extra `List` per subfolder, capped at 100 counted folders per listing so a huge directory cannot turn one browse into a listing storm; entries past the cap keep no count and render as the plain kind. Covered by `listcounts_test.go`. **1. Owner column — NOT done.** `storage.FileInfo` still carries no uploader/creator (`listmeta.go` still deliberately omits it), and the `data-space="personal"` default that hides the Owner column is still in place. The open design question stands: where creator metadata lives given no-DB + S3/Garage, and what happens for files created out-of-band (WebDAV, direct backend). **2. Per-file lock — NOT done.** Still no per-path lock subsystem; the only lock remains the instance boot-guard in `internal/statestore/lock.go`. Remaining scope of this issue = owner metadata (and retiring the `data-space` default with it) + the per-file lock decision.
Author
Owner

Owner column — design ratified with Nikola (2026-09-01)

Research first: classic Nextcloud/ownCloud keep a DB file index (oc_filecache) whose production pathology (100 GB+ tables, per-file SELECTs, million-file loads choking) is the strongest argument for Cairn's no-DB stance. oCIS rebuilt on our philosophy and landed on per-node metadata beside the data — xattrs first, then messagepack sidecars, because xattrs have hard size limits and break on shared filesystems (lesson: one mechanism for both drivers, never xattrs). S3 constraint: ListObjectsV2 never returns user metadata, so x-amz-meta-* or per-file sidecars are a HEAD/GET per file — the N+1 that must not exist on the listing path. Conclusion: read cost must scale with folders browsed, never files stored.

Decisions

D1 — Semantics. Owner = the original uploader/creator of a file or folder, recorded at creation, display-only in v1. Within a space, the space-owner additionally holds ownership over all contents (already expressed by the space model; nothing extra stored per file). Peering deliveries: owner = the recipient; if the recipient is a space, the space-owner. Transferable ownership and owner-as-permission-anchor are explicitly future work and must not be blocked by the data shape.

D2 — Storage: per-folder manifest. One hidden object per directory, .cairn-meta.json, mapping child name → {owner, at}. One extra GET per listing regardless of file count. Lives inside the directory it describes, so folder rename/move carries it for free. Written at the trash layer's level in the stack (below scope, above encrypt) → ciphertext at rest on S3, and invisible/unwritable from every user surface (listing, WebDAV, shares, peering) with reserved-name enforcement. A folder's own record lives in its parent's manifest. Read-modify-write serialised per folder in-process — safe under the single-replica + statestore-lock topology; no CAS needed on Garage.

D3 — Unknown owners: honest em dash + free inference. No record → server-side inference fills what is structurally certain: paths under /home/<user> → that user; space contents → the space-owner. Only genuinely ambiguous files (pre-existing shared-space content, out-of-band backend writes) render "—". No backfill sweep, nothing invented.

D4 — Lifecycle. Move preserves the record (entry migrates between manifests). Copy records the copier. Delete removes the entry; the trash sidecar gains an owner field so restore (user or admin) re-records the original owner. Renames into a trash tree drop the source entry; renames out are handled by the restore path. A failed manifest write never fails the user's operation — owner is best-effort display metadata (unlike holds); log and continue.

D5 — API. GET /api/v1/files items gain owner,omitempty, filled in the existing enrichment pass (with shared/hold/items): one manifest GET per listing, inference applied server-side so the UI never re-implements the rules. Missing/unreadable manifest degrades to pure inference; listing never fails over a badge.

D6 — UI. Owner column live in space and shares contexts, hidden in the personal home — and this is the moment the intentional data-space="personal" default from #311 is retired so data-space reflects real context. Cell = avatar initial + username per the design system (13.5px/500 name treatment); Details panel gains an Owner line; i18n in all four locales.

D7 — Testing. Unit-level: manifest round trip, full lifecycle (move/copy/delete/restore), inference rules, concurrent-upload serialisation, invisibility/unwritability of .cairn-meta.json (same shape as the .trash tests), and a guard asserting exactly one manifest read per listing regardless of item count.

Future-proofing. {owner, at} is a struct, not a bare string: transfer (#D1 future) becomes "write a new value + audit line"; permission-anchoring reads the same record. No migration needed later.

Per-file lock remains the open remainder of this issue and gets its own design pass after owner ships.

## Owner column — design ratified with Nikola (2026-09-01) Research first: classic Nextcloud/ownCloud keep a DB file index (`oc_filecache`) whose production pathology (100 GB+ tables, per-file SELECTs, million-file loads choking) is the strongest argument for Cairn's no-DB stance. oCIS rebuilt on our philosophy and landed on per-node metadata beside the data — xattrs first, then messagepack sidecars, because xattrs have hard size limits and break on shared filesystems (lesson: one mechanism for both drivers, never xattrs). S3 constraint: `ListObjectsV2` never returns user metadata, so `x-amz-meta-*` or per-file sidecars are a HEAD/GET per file — the N+1 that must not exist on the listing path. Conclusion: read cost must scale with **folders browsed**, never files stored. ### Decisions **D1 — Semantics.** Owner = the original uploader/creator of a file or folder, recorded at creation, display-only in v1. Within a space, the space-owner additionally holds ownership over all contents (already expressed by the space model; nothing extra stored per file). Peering deliveries: owner = the recipient; if the recipient is a space, the space-owner. Transferable ownership and owner-as-permission-anchor are explicitly future work and must not be blocked by the data shape. **D2 — Storage: per-folder manifest.** One hidden object per directory, `.cairn-meta.json`, mapping child name → `{owner, at}`. One extra GET per listing regardless of file count. Lives inside the directory it describes, so folder rename/move carries it for free. Written at the trash layer's level in the stack (below scope, above encrypt) → ciphertext at rest on S3, and invisible/unwritable from every user surface (listing, WebDAV, shares, peering) with reserved-name enforcement. A folder's own record lives in its parent's manifest. Read-modify-write serialised per folder in-process — safe under the single-replica + statestore-lock topology; no CAS needed on Garage. **D3 — Unknown owners: honest em dash + free inference.** No record → server-side inference fills what is structurally certain: paths under `/home/<user>` → that user; space contents → the space-owner. Only genuinely ambiguous files (pre-existing shared-space content, out-of-band backend writes) render "—". No backfill sweep, nothing invented. **D4 — Lifecycle.** Move preserves the record (entry migrates between manifests). Copy records the copier. Delete removes the entry; the trash sidecar gains an `owner` field so restore (user or admin) re-records the original owner. Renames into a trash tree drop the source entry; renames out are handled by the restore path. A failed manifest write never fails the user's operation — owner is best-effort display metadata (unlike holds); log and continue. **D5 — API.** `GET /api/v1/files` items gain `owner,omitempty`, filled in the existing enrichment pass (with shared/hold/items): one manifest GET per listing, inference applied server-side so the UI never re-implements the rules. Missing/unreadable manifest degrades to pure inference; listing never fails over a badge. **D6 — UI.** Owner column live in space and shares contexts, hidden in the personal home — and this is the moment the intentional `data-space="personal"` default from #311 is retired so `data-space` reflects real context. Cell = avatar initial + username per the design system (13.5px/500 name treatment); Details panel gains an Owner line; i18n in all four locales. **D7 — Testing.** Unit-level: manifest round trip, full lifecycle (move/copy/delete/restore), inference rules, concurrent-upload serialisation, invisibility/unwritability of `.cairn-meta.json` (same shape as the `.trash` tests), and a guard asserting exactly one manifest read per listing regardless of item count. **Future-proofing.** `{owner, at}` is a struct, not a bare string: transfer (#D1 future) becomes "write a new value + audit line"; permission-anchoring reads the same record. No migration needed later. Per-file lock remains the open remainder of this issue and gets its own design pass after owner ships.
Author
Owner

Per-file lock — design ratified with Nikola (2026-09-02)

Owner and item-counts shipped earlier (v0.6.102–v0.6.105); this settles the last piece. Context that reframed the issue: Cairn already fakes WebDAV locksinternal/dav advertises class 2 and "grants" every LOCK with a token, enforcing nothing (documented MVP choice; Finder/MS Office refuse class-1 servers). So Office clients believe they hold locks today and last-save-wins on concurrent edits. Research anchors: Nextcloud's split between invisible transactional locking and the user-facing files_lock app (three lock types: user/app/token; manual locks indefinite by default; a CVE where unlock permission checks were wrong), RFC 4918 enforcement essentials (423 + precondition bodies, If header, Lock-Token, Timeout/refresh), and the finding that desktop Office co-authoring is SharePoint/OneDrive-only — over WebDAV, Word's exclusive-lock dance IS the feature, and it only works against real enforcement. Co-editing (WOPI + editor server) is acknowledged as a separate future epic (Nikola will raise it himself; deliberately no issue filed).

Decisions

L1 — Scope: manual + real DAV locks in one system. Two lock types from day one: user (manual, row menu, indefinite) and dav (created by real LOCK handling: token, RFC timeout, refresh, auto-expiry). The documented fake is retired, not built beside. The app type is reserved for the future co-editing epic.

L2 — Semantics. A lock protects one FILE's content from other people's writes: Write/Delete/Rename(src or dst)/Copy-onto refused with storage.ErrLocked for everyone but the holder; Open/Stat/List never blocked. Distinct from legal holds (admin compliance instrument protecting existence, subtree-scoped, does not stop editing). Files only in v1 — folder locking mimics the hold authority model and stays out.

L3 — Unlock rights: holder + admin + space-owner (for paths inside their space, resolved via SpacesAPI.owner). Admin/space-owner break-lock is an explicit, audited unlock — there is NO silent admin write-through in the storage layer (one exact permission check in one place; the files_lock CVE lesson).

L4 — Expiry. user locks indefinite by default with an optional instance cap setting (off by default). dav locks always expire per RFC Timeout (instance cap ~1h, floor 30s), reaped lazily; refresh via LOCK+If extends.

L5 — Storage. Tenth statestore-managed file locks.json: path → {owner, type, token?, at, expires?}; resolved backend paths; full in-memory index (enforcement is a map lookup, zero extra I/O, scales independent of file count); synchronous write-through — a lock that fails to persist fails the request (opposite of ownermeta's best-effort, on purpose: a lock is a promise).

L6 — Enforcement. New lockguard storage decorator directly above ownermeta: sees backend paths + ctx user; trash's delete-rename passes through it, so deleting a locked file is refused for free. Holder renaming their file carries the lock; delete removes it. writeErr maps ErrLocked → 423.

L7 — WebDAV. LOCK → real dav lock (opaquelocktoken: UUID, Lock-Token header, lockdiscovery body, Timeout honoured/capped); refresh supported; conflicting lock → 423 + no-conflicting-lock; UNLOCK validates Lock-Token (bad token → 403); writes hit the shared guard, ErrLocked → 423 + lock-token-submitted; PROPFIND reports activelock. Holder identity exempts ordinary writes (no If-token demanded on PUT — identity is the stronger check we actually have). DAV-created locks can be lifted from the web UI.

L8 — UI. Row menu Lock/Unlock (permission-aware), the existing data-t="lock" chip binds with "Locked by X" title, refused writes surface the 423 message as a toast, listing enrichment gains locked/lockOwner beside shared/hold (in-memory lookups in the existing pass). i18n ×4, en-GB.

L9 — Audit. lock, unlock, and break-lock all written to the audit log.

L10 — Future, designed-for but not built. (a) app locks for the co-editing epic. (b) Nikola's space-owner extension: an instance setting ("Space owners may lock folders in their spaces", off by default) with an admin-dashboard row and a proper space-owner UX for locking folders/subtrees — folder-lock semantics (ancestor checks, rename rules, DAV depth-infinity) designed when that ships.

Testing: store grant/deny/persistence/expiry; guard verb × identity matrix incl. trash path and lock-follows-rename; DAV grant/refresh/conflict/UNLOCK-validation/PUT-423; API permission matrix incl. space-owner; mutation check on the guard predicate.

## Per-file lock — design ratified with Nikola (2026-09-02) Owner and item-counts shipped earlier (v0.6.102–v0.6.105); this settles the last piece. Context that reframed the issue: **Cairn already fakes WebDAV locks** — `internal/dav` advertises class 2 and "grants" every LOCK with a token, enforcing nothing (documented MVP choice; Finder/MS Office refuse class-1 servers). So Office clients believe they hold locks today and last-save-wins on concurrent edits. Research anchors: Nextcloud's split between invisible transactional locking and the user-facing `files_lock` app (three lock types: user/app/token; manual locks indefinite by default; a CVE where unlock permission checks were wrong), RFC 4918 enforcement essentials (423 + precondition bodies, If header, Lock-Token, Timeout/refresh), and the finding that desktop Office co-authoring is SharePoint/OneDrive-only — over WebDAV, Word's exclusive-lock dance IS the feature, and it only works against real enforcement. Co-editing (WOPI + editor server) is acknowledged as a separate future epic (Nikola will raise it himself; deliberately no issue filed). ### Decisions **L1 — Scope: manual + real DAV locks in one system.** Two lock types from day one: `user` (manual, row menu, indefinite) and `dav` (created by real LOCK handling: token, RFC timeout, refresh, auto-expiry). The documented fake is retired, not built beside. The `app` type is reserved for the future co-editing epic. **L2 — Semantics.** A lock protects one FILE's content from other people's writes: Write/Delete/Rename(src or dst)/Copy-onto refused with `storage.ErrLocked` for everyone but the holder; Open/Stat/List never blocked. Distinct from legal holds (admin compliance instrument protecting existence, subtree-scoped, does not stop editing). Files only in v1 — folder locking mimics the hold authority model and stays out. **L3 — Unlock rights: holder + admin + space-owner** (for paths inside their space, resolved via `SpacesAPI.owner`). Admin/space-owner break-lock is an explicit, audited unlock — there is NO silent admin write-through in the storage layer (one exact permission check in one place; the files_lock CVE lesson). **L4 — Expiry.** `user` locks indefinite by default with an optional instance cap setting (off by default). `dav` locks always expire per RFC Timeout (instance cap ~1h, floor 30s), reaped lazily; refresh via LOCK+If extends. **L5 — Storage.** Tenth statestore-managed file `locks.json`: path → `{owner, type, token?, at, expires?}`; resolved backend paths; full in-memory index (enforcement is a map lookup, zero extra I/O, scales independent of file count); synchronous write-through — a lock that fails to persist fails the request (opposite of ownermeta's best-effort, on purpose: a lock is a promise). **L6 — Enforcement.** New `lockguard` storage decorator directly above ownermeta: sees backend paths + ctx user; trash's delete-rename passes through it, so deleting a locked file is refused for free. Holder renaming their file carries the lock; delete removes it. `writeErr` maps ErrLocked → 423. **L7 — WebDAV.** LOCK → real `dav` lock (`opaquelocktoken:` UUID, Lock-Token header, lockdiscovery body, Timeout honoured/capped); refresh supported; conflicting lock → 423 + `no-conflicting-lock`; UNLOCK validates Lock-Token (bad token → 403); writes hit the shared guard, ErrLocked → 423 + `lock-token-submitted`; PROPFIND reports `activelock`. Holder identity exempts ordinary writes (no If-token demanded on PUT — identity is the stronger check we actually have). DAV-created locks can be lifted from the web UI. **L8 — UI.** Row menu Lock/Unlock (permission-aware), the existing `data-t="lock"` chip binds with "Locked by X" title, refused writes surface the 423 message as a toast, listing enrichment gains `locked`/`lockOwner` beside shared/hold (in-memory lookups in the existing pass). i18n ×4, en-GB. **L9 — Audit.** lock, unlock, and break-lock all written to the audit log. **L10 — Future, designed-for but not built.** (a) `app` locks for the co-editing epic. (b) Nikola's space-owner extension: an instance setting ("Space owners may lock folders in their spaces", off by default) with an admin-dashboard row and a proper space-owner UX for locking folders/subtrees — folder-lock semantics (ancestor checks, rename rules, DAV depth-infinity) designed when that ships. **Testing:** store grant/deny/persistence/expiry; guard verb × identity matrix incl. trash path and lock-follows-rename; DAV grant/refresh/conflict/UNLOCK-validation/PUT-423; API permission matrix incl. space-owner; mutation check on the guard predicate.
Author
Owner

All three parts are now shipped:

  1. Item counts — v0.6.103 line (folders show "N items").
  2. Owner column — v0.6.102/103 (D1–D7: ownermeta per-folder manifests, API inference, UI column).
  3. Per-file lockv0.6.106, PR #393, implementing L1–L10 as ratified above: internal/storage/filelock (statestore-backed locks.json + guard decorator between trash and ownermeta), storage.ErrLocked → 423, POST/DELETE /api/v1/lock with holder/admin/space-owner unlock and lock/unlock/break-lock audit events, locked/lockOwner listing enrichment, real WebDAV LOCK/UNLOCK (tokens, Timeout 30s–1h, refresh, no-conflicting-lock / lock-token-submitted preconditions, activelock in PROPFIND, no downgrade of a manual lock by a DAV LOCK), and the UI (row-menu Lock/Unlock on files, "Locked" chip with holder tooltip, toasts, i18n ×4).

Deployed to both dogfoods.

Deferred, on record: L4's admin-configurable expiry cap for user locks (needs a settings field + admin row — currently user locks are simply indefinite); the space-owner folder-locking instance setting (L2 note); permission-aware hiding of the Unlock menu entry (server enforces the permission matrix; the client always shows Unlock on a locked row and surfaces the 403). App-type locks remain reserved for the co-editing epic (L10).

Closing.

All three parts are now shipped: 1. **Item counts** — v0.6.103 line (folders show "N items"). 2. **Owner column** — v0.6.102/103 (D1–D7: ownermeta per-folder manifests, API inference, UI column). 3. **Per-file lock** — **v0.6.106, PR #393**, implementing L1–L10 as ratified above: `internal/storage/filelock` (statestore-backed `locks.json` + guard decorator between trash and ownermeta), `storage.ErrLocked` → 423, `POST/DELETE /api/v1/lock` with holder/admin/space-owner unlock and `lock`/`unlock`/`break-lock` audit events, `locked`/`lockOwner` listing enrichment, real WebDAV LOCK/UNLOCK (tokens, Timeout 30s–1h, refresh, `no-conflicting-lock` / `lock-token-submitted` preconditions, activelock in PROPFIND, no downgrade of a manual lock by a DAV LOCK), and the UI (row-menu Lock/Unlock on files, "Locked" chip with holder tooltip, toasts, i18n ×4). Deployed to both dogfoods. **Deferred, on record:** L4's admin-configurable expiry cap for user locks (needs a settings field + admin row — currently user locks are simply indefinite); the space-owner folder-locking instance setting (L2 note); permission-aware hiding of the Unlock menu entry (server enforces the permission matrix; the client always shows Unlock on a locked row and surfaces the 403). App-type locks remain reserved for the co-editing epic (L10). Closing.
Cordy closed this issue 2026-09-02 19:17:47 +00:00
Sign in to join this conversation.
No labels
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#315
No description provided.