# collab-buckets plugin — roadmap > Phase 1, 2 and 3 are shipped: metadata + membership sync, bucket-bar > integration, and full card sync with encrypted round-trip and LWW > conflict resolution. Phase 4 is polish — edit locks, ownership > transfer, member presence, etc. Phase 5 is hardening. ## Phase 3 — card sync (DONE ✓) - [x] **`collab_card_state` table (local)** — maps a local `cards.id` to a collab `bucket_uid` + `relay_uid` plus `last_push_at` and `last_remote_at` for LWW tracking. Poll phase 1 finds dirty cards via `c.updated_at > cs.last_push_at`. - [x] **Save propagation without editor coupling** — the poll's push phase is query-based (no trigger, no hook). Whenever the user saves a card and the core bumps `cards.updated_at`, the next poll within 30s finds it dirty and pushes. - [x] **Relay `collab_cards` table + routes** — `POST /api/collab-cards` decrypts-reencrypts via inter-tenant key; `GET ?since=` re-encrypts per recipient; `DELETE /{bucket}/{card}` marks a tombstone with a 7-day TTL (cleaned up by bootstrap.php). - [x] **Poll extension** — `collabSyncCards()` runs three phases (tombstones → push → pull) with a temp-table suppress flag so the delete trigger doesn't re-enqueue during apply. - [x] **Card visibility** — collab buckets already take part in the core bucket-bar's click-to-filter flow, so cards synced into them automatically show up on the board. - [x] **Card deletion propagation** — BEFORE DELETE trigger writes to `collab_tombstones`. Drained in the next poll as a DELETE. Suppressed during the apply phase so remote deletes don't loop back as local tombstones. - [x] **Conflict losers as "(conflict from X)" duplicates** — when the apply phase is about to LWW-clobber a local card that had edits since our last push (`local.updated_at > collab_card_state.last_push_at`), we now INSERT a copy of the local version into a local-only "Conflicts" bucket with a `(conflict from ) ...` title prefix before the overwrite. The poll response carries a `conflicts` count; the frontend pops a toast pointing the user at the Conflicts bucket when it's non-zero. - [x] **Moving a card between collab buckets (or out to a local one)** — the push phase now has a pre-pass that finds cards where `collab_card_state.bucket_uid` no longer matches the card's current bucket collab_uid. For each such card it sends a tombstone to the old bucket. If the card is still in a (different) collab bucket, state is rewritten with the new `bucket_uid` and `last_push_at = NULL` so the main push loop re-uploads under the new bucket. If the card left collab entirely, the state row is removed. ## Phase 4 — polish - [x] **Per-card edit lock** — when the editor opens on a collab card the plugin heartbeats `POST /api/collab-locks {bucket_uid, card_uid}` every 30 s with a 60 s TTL on the relay. The lock list is folded into the regular `/api/collab-buckets/poll` response so no extra round-trips are needed. Foreign locks show a 🔒 badge on the affected board card + a subtle inset outline. Releases on editor close and best-effort on `beforeunload` via `fetch(..., keepalive)`. Advisory only — users can still force-edit; LWW + conflict stashing remain the final arbiter. Core `editor.js` dispatches `hyprcrd:editor-open` / `hyprcrd:editor-close` `CustomEvent`s so the plugin hooks in without monkey-patching. - [x] **Ownership transfer** — `PATCH /api/collab-buckets/{uid}` accepts `{owner: }`. The relay validates that the new owner is currently a member, swaps `owner_id` + `owner_username`, and forces both the caller and the new owner to stay in the member list on the same request. Surfaced in the UI via a new "Members & owner…" entry in the bucket context menu that opens a plugin dialog with a member picker + owner dropdown (only shown when the caller is the current owner; other members see a read-only view). - [ ] **Position propagation (optional)** — reorder is purely local right now. If multiple members expect the same order, we'd need to sync `position` via the relay, but it's probably fine to leave this as a per-user preference. - [ ] **Member presence indicator** — show a small dot next to usernames in the members panel when they were seen by the relay within the last minute (reuse chat's heartbeat or add a collab-specific ping). - [ ] **"Recently active members"** — show avatars (first letter) of members who touched a card in the last hour, on the collab bucket tab. - [ ] **Read receipts** — "3 / 5 seen" for the latest update on each collab bucket. Requires the relay to store per-member `last_seen_at` — bigger schema change. ## Phase 5 — hardening & UX - [x] **Migration of existing local buckets** — the bucket context menu on a non-collab bucket shows a "Make collaborative…" entry when the plugin is loaded. It opens a plugin dialog where the owner picks members, then `POST /api/collab-buckets/upgrade {bucket_id, members}` generates a new `uid`, pushes it to the relay, and writes `collab_uid` onto the same local `buckets` row — no card shuffling, no id rewrite. The first poll after the upgrade sees every card in the bucket as dirty (no `collab_card_state`) and pushes them. - [x] **Undo for destructive actions** — local bucket delete/leave now hooks into the existing `pushUndo` system: the undo entry recreates the bucket via `POST /api/buckets`, restores the original color+position with PATCH, and re-attaches every card that pointed at the old row. Collab bucket delete/leave is intentionally *not* wired — restoring would need to recreate on the relay under a new uid and re-attach cards there too, which is messy and less valuable since collab deletes are usually deliberate. - [x] **Resilient create via outbox** — a new `collab_bucket_outbox(id, bucket_id, members_json, queued_at)` table. The create/upgrade flow always creates the local `buckets` row first and then tries `collabAttemptUpgrade()`. On relay failure the intent is queued in the outbox and the next poll retries. The response includes `queued: true` so the frontend can show a "saved locally, will sync later" info toast; a follow-up poll that successfully drains the outbox fires a second toast. Stale outbox entries for deleted buckets are dropped automatically. - [x] **Rate limiting on collab endpoints** — every collab route now funnels through the existing relay `isRateLimited()` helper with generous caps (120/min for GET, 240/min for POST). Over-limit requests get a clean `429 rate limited` response. - [ ] **Inline member picker in the bucket context menu** — currently needs the manage-modal for any change. A quick `+user` / `-user` flyout would help for tiny edits. - [ ] **Owner-side member approval flow** — owners can still add any relay user unilaterally. A pending-invite state with accept/reject on the invitee side would be nicer for consent but requires a new relay table + plumbing. ## Known limitations (current) - **LWW conflicts still clobber on the remote side.** When two users save the same card within a few seconds, the later writer wins. The earlier writer gets their local version stashed into a `Conflicts` bucket and a toast notification — nothing is lost — but their version is no longer the canonical one anyone else sees. Phase 4 edit-locks prevent the race in the first place. - **Up to ~30 s sync latency.** Changes propagate on the next poll, not instantly. Acceptable for MVP; a "push now" hook after save would cut latency to ~1 s. - **Full resync on every poll.** Cheap for small member counts but grows O(N_buckets × N_members) on the relay side. A `since=updated_at` filter would help — adding it is safe because the poll also has to handle full removals (kicked from bucket), and we can fold the deletion list into the response. - **Bucket metadata is plaintext on the relay.** Name + color + member list are visible to a relay admin with DB access. Card content (Phase 3) will be encrypted end-to-end via the existing decrypt-reencrypt hop; bucket metadata could follow the same pattern but adds complexity for minimal gain and makes member picking harder. - **No offline support.** Creating a bucket while the relay is unreachable fails hard. Phase 5 outbox pattern would fix this. - **Race on UID generation.** The client generates `bin2hex(random_bytes(8))` = 64 bits of entropy. Collisions between independent clients are astronomically unlikely, but the relay rejects them with 409 if they ever happen. No retry on the client side yet.