Files
Oxicloud/docs/plan/message-bus.md
T
Edouard Vanbelle 899bbd13a6 fix(msg-bus): prevent race on reconnect
and update plan
2026-09-11 03:20:45 +02:00

59 KiB
Raw Blame History

Plan — Message bus over WebSocket

Context

OxiCloud today has no server-push channel. Every "live-ish" surface (folder listing, job dashboard, share dialog, admin session count) is either stale-until-refresh or polled by the SPA. That leaves a whole category of features unreachable — collab editing, presence, notifications, sync-client push invalidation — and it makes existing surfaces feel dated compared to Google Drive, Notion, Nextcloud, and M365.

This plan introduces a single message bus over WebSocket that any service can publish facts to and any client can subscribe to. Collab editing is one consumer on top; folder-live updates, notifications, job progress, presence, and sync-client push invalidation follow with almost no extra scaffolding.

Status — 2026-09-11

The feat/message-bus branch delivers D + F + follow-ups shipped end-to-end on the FE and BE, verified by S1–S11 in the api-test smoke suite plus manual multi-user E2E. Live today:

  • Bus core — MessageBus port + InProcessMessageBus + NoopReplicator. 📤 bus publish trace under RUST_LOG=oxicloud::message_bus=debug.
  • WS handler (/api/rt/ws) — JSON-RPC 2.0, rt.subscribe / unsubscribe / event / revoked / ping / pong, server-initiated RFC 6455 keepalive Ping.
  • Auth for the WS upgrade — F ticket flow shipped. POST /api/rt/ticket mints a one-shot 30 s ticket under the full auth+DPoP+CSRF chain; the browser passes it via Sec-WebSocket-Protocol: oxi.ticket.<uuid>. Also accepts Authorization: Bearer <jwt> for programmatic clients (rt-hurl-helper). Route mounted OUTSIDE protected_api so the standard DPoP-required middleware doesn't 401 browsers that can't attach a DPoP: header to new WebSocket(). See handlers/rt_ws.rs module doc.
  • Events firing end-to-end — every MessageBusEvent variant except CRDT-flavoured ones:
    • FileCreated / Renamed / Moved / Deleted (via FileUploadService + FileManagementService)
    • FolderCreated / Renamed / Moved / Deleted (via FolderService for direct paths; TrashService publishes FolderDeleted on the trash-first path — the FE hits that path via DELETE /api/folders/{id})
    • AuthzChanged (via ShareService::revoke_grant) drives the grant-revocation eviction cascade.
  • Grant-revocation eviction (Slice C) — WS handler auto-subscribes each session to user:{caller}:authz; on AuthzChanged the reader translates to SessionOut::EvictFolders and emits rt.revoked per evicted topic. Scope is per-topic; the session itself and unrelated subscriptions survive.
  • FE composables — useTopic (generic), useFolderTopic (folder-view sugar with per-verb + onRevoked + onReconnect handlers), useReconnect (session-level, fires after 2nd+ open). Types generated from AsyncAPI via @asyncapi/modelina in frontend/src/lib/generated/message-bus/; check-message-bus-spec CI + pre-pull-request block on drift.
  • Folder-view live refresh — +page.svelte wires every onFile*/onFolder* handler to scheduleLiveReload, 100 ms coalesce. Revocation → toast + goto('/files'). Reconnect → refetch via onReconnect (bridges the "events lost during outage window" gap; see project_message_bus_reconnect_gap memory). Actor-echo skip was REMOVED for multi-tab correctness — refetch is idempotent, ~30 ms per self-mutation.
  • Client-side resilience — jittered exponential backoff (250 ms → 30 s cap), circuit breaker at 20 consecutive failures (~5 minutes of retry — covers a cargo-release restart), untrack in every mutation entry point so $state reads don't leak into caller $effect deps.
  • AuthZ tested — S3 (folder no_read), S4 (nonexistent folder = anti-enum parity), S9 (cross-user identity topic → topic_forbidden).
  • Ticket tested — S10 (happy path), S11 (single-use replay rejected).

Deferred and still open — see the Roadmap section and the project_message_bus_reconnect_gap memory:

  • Notifications table + bell (E) — topic + producer + auto-sub land here. Same pattern as :authz.
  • Presence (Phase B) — folder:{id}:presence topic + awareness frames.
  • Yjs collab — docs/plan/markdown-collab.md, depends on the binary-frame routing this plan sketches but doesn't ship.
  • Broker replicator (Postgres LISTEN/NOTIFY or Redis) — for multi-instance and durable event log. BusReplicator port declared, NoopReplicator wired today.

Non-goals

  • Persistent event log with "you missed these" replay. Durable state lives in real tables (notifications, collab.doc_sessions, …); the bus is a live-delivery optimization, always best-effort.
  • Chat / DM / voice / video / screen share. Explicitly out of scope for OxiCloud — that's Nextcloud Talk territory, not a file-server job.
  • Wildcard subscriptions (folder:*). Breaks per-subscribe AuthZ and makes revocation semantics fuzzy.
  • Cross-user subscriptions. Privacy + AuthZ risk. Admins subscribe to admin:* topics, never to another user's private feed.

Architecture — 3 layers, clean seams

┌──────────────────────────────────────────────────────────────────────┐
│ SERVICE LAYER                                                        │
│                                                                      │
│  FileMgmtService.create_file() ── after commit ──▶ bus.publish(...)  │
│  ShareService.grant()          ── after commit ──▶ bus.publish(...)  │
│  JobRegistry step progress     ─────────────────▶ bus.publish(...)   │
│  CollabSessionService.apply()  ─────────────────▶ bus.publish(...)   │
│                                                                      │
└───────────────────────────────┬──────────────────────────────────────┘
                                │  publish(&Topic, MessageBusEvent)
                                ▼
┌──────────────────────────────────────────────────────────────────────┐
│ MESSAGE BUS   (MessageBus trait — application/ports)               │
│                                                                      │
│   InProcessMessageBus (v1)                                          │
│     DashMap<Topic, broadcast::Sender<MessageBusEvent>>                 │
│                                                                      │
└──────┬───────────────────────────────────────────────────────────────┘
       │
       │       ┌──────────────────────────────────────────────────────┐
       │       │ REPLICATOR (optional, feature-flagged)               │
       │       │                                                      │
       │       │  BusReplicator trait ── separate port                │
       │       │    - v1: NoopReplicator (single-instance)            │
       │       │    - v2: PgListenReplicator (pg_notify)              │
       │       │    - v3: BrokerReplicator (RabbitMQ / NATS)          │
       │       │                                                      │
       │       │  Sits BESIDE InProcessMessageBus, forwards          │
       │       │  local publishes outbound + inbound events           │
       │       │  from the broker back into local publish.            │
       │       └──────────────────────────────────────────────────────┘
       ▼
┌──────────────────────────────────────────────────────────────────────┐
│ WS HANDLER   (interfaces/api/handlers/rt_ws.rs)                      │
│                                                                      │
│   One BusSession per WS: HashSet<Topic> + outbound mpsc         │
│   - subscribe/unsubscribe frames → bus.subscribe(topic)              │
│   - each subscribed stream drains into the outbound mpsc             │
│   - AuthZ at subscribe (once), evict on grant-revoked                │
│                                                                      │
└──────────────────────────────────────────────────────────────────────┘

The seam that keeps RabbitMQ/NATS doors open is the replicator, not the bus. Services and the WS handler only ever see the local MessageBus. A future BrokerReplicator publishes outbound + injects inbound. Zero touch to callers.

Backend components

1. Port + event types (application/ports/message_bus_ports.rs)

// Topic is a typed enum, not a string. Prevents typos, gives
// exhaustive matching for the AuthZ gate, encodes stably to
// wire keys for any broker (RabbitMQ topic exchange, NATS subject).
pub enum Topic {
    Folder(FolderId),
    File(FileId),
    Drive(DriveId),
    UserNotifications(UserId),
    UserAuthz(UserId),
    UserSessions(UserId),
    UserUploads(UserId),
    Job(JobId),
    Collab(FileId),
    CollabAwareness(FileId),
    FolderPresence(FolderId),
    FilePresence(FileId),
    FileComments(FileId),
    Calendar(CalendarId),
    AddressBook(AddressBookId),
    AdminSessions,      // admin-only
    AdminAudit,         // admin-only, sampled
}

impl Topic {
    pub fn to_wire_key(&self) -> String;        // stable dotted form
    pub fn parse(s: &str) -> Result<Self, ParseTopicErr>;
    pub fn required_perm(&self) -> AuthzCheck;  // used by the AuthZ gate
}

/// Wire mirror of `domain::services::authorization::Subject`.
/// Kept as its own type so the bus payload schema can evolve
/// independently of the domain enum.
#[derive(Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PrincipalRef {
    User  { id: UserId },
    Group { id: GroupId },
    Token { id: TokenId },   // anonymous public share link
}

#[derive(Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum MessageBusEvent {
    // Folder / File verbs — thin facts only, client refetches details.
    FileCreated  { file_id: FileId, name: String, parent_id: FolderId, actor: UserId },
    FileDeleted  { file_id: FileId, parent_id: FolderId, actor: UserId },
    FileRenamed  { file_id: FileId, old_name: String, new_name: String, actor: UserId },
    FileMoved    { file_id: FileId, from: FolderId, to: FolderId, actor: UserId },
    FolderCreated { folder_id: FolderId, parent_id: FolderId, actor: UserId },
    // Jobs
    JobStep      { job_id: JobId, step: u32, message: String },
    JobFinished  { job_id: JobId, outcome: JobOutcome },
    // Notifications
    Notification { notification_id: NotifId, kind: NotifKind },
    // Sharing — principal is any Subject (user, group, or public-link token).
    // `affected_users` is populated by the producer only for the group case,
    // so consumers of `file:{id}:shares` don't need to expand membership.
    ShareGranted {
        file_id: FileId,
        principal: PrincipalRef,
        role: GrantRole,
        affected_users: Option<Vec<UserId>>,
    },
    ShareRevoked {
        file_id: FileId,
        principal: PrincipalRef,
        affected_users: Option<Vec<UserId>>,
    },
    // Group membership changes — cascade grants to/from the affected user.
    GroupMemberAdded   { group_id: GroupId, user_id: UserId, actor: UserId },
    GroupMemberRemoved { group_id: GroupId, user_id: UserId, actor: UserId },
    // AuthZ eviction / re-evaluation signal
    AuthzChanged { affected: Vec<ResourceId> },
    // Presence (Phase B)
    PresenceJoined { user_id: UserId, display_name: String, color: String },
    PresenceLeft   { user_id: UserId },
    PresenceCursor { user_id: UserId, position: PresencePosition },
    // Collab (binary bytes on the wire, kept opaque in the enum)
    CrdtUpdate    { bytes: Bytes },
    // ... one variant per verb; enum > strings per project convention.
}

#[async_trait]
pub trait MessageBus: Send + Sync {
    /// Fire-and-forget. SYNC (not async) — services must not await
    /// under a DB transaction.
    fn publish(&self, topic: &Topic, event: MessageBusEvent);

    /// Returns a Stream so the impl can change (broadcast, mpsc,
    /// pg listener) without churn.
    fn subscribe(&self, topic: &Topic) -> Pin<Box<dyn Stream<Item = MessageBusEvent> + Send>>;
}

/// Kept SEPARATE from MessageBus so v2/v3 wiring is drop-in.
#[async_trait]
pub trait BusReplicator: Send + Sync {
    /// Called whenever the local bus publishes; may forward to broker.
    fn on_local_publish(&self, topic: &Topic, event: &MessageBusEvent);

    /// Long-running consumer task: reads remote messages and
    /// re-publishes locally. Started by DI, returns on shutdown.
    async fn run(self: Arc<Self>, shutdown: CancellationToken) -> Result<(), BusErr>;
}

Group principals and fan-out

Grants target any Subject — User(Uuid), Group(Uuid), or Token(Uuid) (public share link). A single ShareGranted event therefore has two distinct audiences with different delivery paths:

Audience Topic Payload use
Share dialog on the resource (anyone with Share watching it) file:{F}:shares Thin fact: "new grantee X with role R"; UI refetches grant list
Each affected user (persistent "shared with you") user:{member}:notifications (one publish per member) Becomes a notif.notifications row via NotificationService::create

The bus never expands groups. NotificationService is the group-expansion boundary. MessageBus only fans out topics that already exist as concrete user:* streams.

Post-commit sequence for ShareService::grant(file=F, principal=Group(G), role=R):

1. Insert grant row, COMMIT.
2. members = GroupService::expand_transitive(G)   // via closure table
3. bus.publish(Topic::File(F).shares(),
       ShareGranted {
           principal: PrincipalRef::Group { id: G },
           role: R,
           affected_users: Some(members.iter().copied().collect()),
       })                                          // share-dialog fan-out
4. for member in &members {
       notification_service.create(NewNotification {
           recipient_id: *member,
           kind: "share.granted_via_group_membership",
           subject_type: "file", subject_id: F,
           actor_id: caller,
           data: json!({ "via_group": G, "role": R }),
       });
       // create() inserts row AND publishes to user:{member}:notifications
   }
5. for member in &members {
       bus.publish(Topic::UserAuthz(*member),
           AuthzChanged { affected: vec![Resource::File(F).into()] });
   }                                                // WS handler re-checks subs

Token(_) principals (public share links) fan out to file:{F}:shares and to a single user:{creator}:notifications (kind share.link_created / share.link_revoked). No AuthzChanged — token holders don't have WS sessions in this model.

GroupMemberAdded { group_id, user_id } triggers the mirror cascade: enumerate the group's grants → synthesize one share.granted_via_group_membership notification per affected resource for the new user → one AuthzChanged for the resource set. Removed runs the revocation mirror.

Bounded fan-out is baked in from day 1 (see § Limits & backpressure): groups larger than max_notification_fanout (default 1000) drop the per-user notifications with an event = "notification.fanout_truncated" audit line; the file:{F}:shares event still fires, so the share dialog stays accurate, and the recipients discover the grant via UI on next visit. This case is realistically the "all-employees" scenario where individual bell pings are noise anyway.

Membership snapshotting: GroupService::expand_transitive runs at the transaction boundary — we do NOT re-expand at publish time, because a concurrent membership edit would then leak or duplicate deliveries. The members set is captured then closed over into the post-commit block.

Coalescing: NotificationService::create de-dupes on (recipient, kind, subject_type, subject_id, day) within a short window. Alice in both G1 and G2, both granted F, gets one notification, not two.

2. In-process impl (infrastructure/services/in_process_message_bus.rs)

  • DashMap<Topic, broadcast::Sender<MessageBusEvent>>, capacity 256 per topic.
  • subscribe creates the entry lazily; wraps Receiver in BroadcastStream (converts Lagged into a stream-level marker; WS handler kills that session with a revoked frame, reason slow_consumer).
  • Background GC: when a topic's receiver_count() == 0 for >60 s, drop the sender.

3. Replicator scaffolding (day-1)

  • NoopReplicator in v1. Wired in DI as Arc<dyn BusReplicator>.
  • InProcessMessageBus::publish calls replicator.on_local_publish(...) after local fan-out.

Futures:

  • v2 — PgListenReplicator. pg_notify('oxi_rt', serde_json::to_string(event)) outbound; dedicated sqlx::PgListener connection inbound. Payload cap ~8 KB fine because events are thin. No new deployed service — reuses the existing PG.
  • v3 — BrokerReplicator.
    • RabbitMQ: topic exchange oxi_rt, per-server exclusive auto-delete queue bound to # (or per-topic bindings for broker-side filtering). Non-durable messages, no user-level queues.
    • NATS: subject hierarchy = oxi.rt.folder.{id}, oxi.rt.job.{id}, etc. Topic::to_wire_key() maps directly. Core NATS (no JetStream) — ephemeral is the point.

Invariant for any broker impl: no user- or session-scoped state at the broker. Servers hold sessions; the broker is stateless fan-out. Keeps replicator swaps painless and prevents per-user queue leaks.

4. WS handler (interfaces/api/handlers/rt_ws.rs)

  • Route GET /api/rt/ws.
  • Auth strategy — three accepted paths, all reuse the existing auth middleware:
    • Browser session cookie (oxicloud_access or whichever cookie the auth middleware validates on REST). The WS upgrade request carries cookies by default; the same auth_middleware + CurrentUserId extractor produces caller_id. No new code path.
    • Bearer JWT via Sec-WebSocket-Protocol — Sec-WebSocket-Protocol: oxi.rt.v1, authorization.bearer.<jwt>. Standard workaround for browser WebSocket (which can't set an Authorization header) and native clients like our rt-hurl-helper. The handler reads the second subprotocol element, validates the JWT via the same path as the REST auth middleware, and echoes back the base subprotocol name in the handshake response.
    • Ticket flow (deferred until DPoP deployments matter) — POST /api/rt/ticket issues a 30-second one-shot ticket, URL is /api/rt/ws?ticket=…. NOT MVP; added when DPoP-strict deployments make cookie/bearer over WS awkward. Callers today have no functional need for it.
  • On upgrade:
    • Extract caller_id from the auth mechanism above.
    • Auto-subscribe to user:{caller}:notifications, user:{caller}:authz, user:{caller}:sessions.
    • Spawn BusSession actor: owns HashSet<Topic>, outbound mpsc::Sender<WsMessage> (bounded 512), one reader task per subscribed topic.
  • Per-frame:
    • subscribe: dispatch on topic.required_perm() -> AuthzCheck and run the matching gate. Three classes exist — resource-scoped (typically Read, sometimes Share / Comment), identity-scoped (caller_id == subject_uuid, no admin bypass), role-scoped (caller.role == Admin); plus the bespoke job-originator-or-admin check for job:{id}. Full matrix in § AuthZ model. Deny → denied frame + audit event = "message_bus.subscribe_denied". Allow → subscribe on bus, ack.
    • unsubscribe: drop the reader task for that topic.
    • ping/pong for keepalive.
    • Binary CRDT frame: route to CollabSessionService (see docs/plan/markdown-collab.md), not the generic path.
  • On user:{caller}:authz event: walk session's subs, re-check each, evict any that lost access (revoked frame with reason grant_revoked).
  • On resource-delete: bus publishes AuthzChanged for affected → same eviction path.
  • Outbound queue full → close WS with 1013 "try again later"; client reconnects, refetches, resubs.

5. Service integration — publish AFTER commit

Rule: bus.publish is called after the DB transaction commits, never before, never inside. If publish were inside the tx, a rollback would still fan out to clients. If publish were async and awaited, a slow subscriber could hold the tx open.

Pattern: services return (result, Vec<MessageBusEvent>) from the tx boundary; the calling layer publishes after commit. Or a TxCommitHook queues events and flushes on commit. Pick one, apply everywhere.

Frontend components

1. Singleton client (lib/message-bus/client.svelte.ts)

Location is lib/message-bus/ (subsystem dir, mirrors lib/auth/ and lib/upload/ — see frontend/AGENTS.md), NOT lib/stores/ — the client is subsystem-scoped plumbing, not global reactive state that routes read from.

  • Fetches a ticket via POST /api/rt/ticket (through apiFetch, so DPoP is applied; getCsrfHeaders() merged in for the state- changing POST). Ticket then passes on the WS upgrade via Sec-WebSocket-Protocol: oxi.ticket.<uuid> (NOT a query param — keeps the token off access logs and out of Referer / URL bar).
  • Opens wss://<same-origin>/api/rt/ws with the subprotocol.
  • Refcounted subscriptions: #subs: Map<TopicKey, { count, handlers, revokedHandlers, acked }>.
  • On first refcount of a topic: send rt.subscribe; on last drop: send rt.unsubscribe.
  • On reconnect: replay every already-known topic (client-side state survives the disconnect); fire onReconnect handlers so consumers refetch and catch up on events dropped during the outage window.
  • Backoff: exponential (250 ms → 30 s), full-jitter. Circuit breaker at 20 consecutive failures (~5 minutes of retry — comfortably covers a cargo-release restart); trip logs one error line and stops until messageBus.reconnect() is called or the page reloads.
  • Reactive-safety rule: every mutation entry point (subscribe, onReconnect, reconnect, close, #call) wraps its $state reads in untrack(() => …). Without this a caller's $effect inherits a hidden dep on state, and each transition (idle → connecting → connected → disconnected → …) re-fires the effect — an observed 1000+/s loop on server-down. See the feedback-svelte5-untrack-mutation-methods memory for the general rule and the docstring on subscribe for the concrete case.
  • Health: state = $state<ConnectionState> + latencyMs = $state<number | null> exposed for a future debug indicator (no UI consumes them yet — silent MVP).

2. Composables

// Generic — subscribe to any topic.
useTopic(topic, onEvent, onRevoked?)

// Folder-view sugar — per-verb handlers + reconnect hook.
useFolderTopic(() => folderId, {
    onFileCreated, onFileRenamed, onFileMoved, onFileDeleted,
    onFolderCreated, onFolderRenamed, onFolderMoved, onFolderDeleted,
    onRevoked,      // grant revoked, subscription evicted server-side
    onReconnect,    // WS came back; consumers refetch to catch up
})

// Session-level reconnect (fires on 2nd+ open, never initial).
useReconnect(cb)

Handles $effect lifecycle (subscribe on mount, unsubscribe on destroy). Zero connection awareness in components.

Wire protocol

Two wire formats share the same WS connection:

  • Control + notifications: JSON-RPC 2.0 — universally recognized, no library needed on either side, standard id-correlated responses, standard error object shape, id-less notifications for server-pushed events. Adopts the same well-known framing as Ethereum node WS APIs, LSP-over-WS, and countless other services; costs ~30 bytes/message over a bespoke shape and buys instant "oh, it's JSON-RPC" recognition + off-the-shelf client compat.
  • CRDT binary frames: Yjs sync protocol — de-facto standard in the Yjs ecosystem, kept as-is because it's the reason we picked Yjs.

Method namespace for our JSON-RPC methods: rt.* — a short opaque prefix reserved for message-bus methods. Prevents collisions if we ever expose additional RPCs on the same WS (not planned, but the namespace costs nothing).

JSON-RPC frames (control + events)

// c → s (requests — id-correlated)
{ "jsonrpc": "2.0", "id": 42, "method": "rt.subscribe",
  "params": { "topic": "folder:abc" } }
{ "jsonrpc": "2.0", "id": 43, "method": "rt.unsubscribe",
  "params": { "topic": "folder:abc" } }
{ "jsonrpc": "2.0", "id": 44, "method": "rt.ping" }

// s → c (responses to requests — same id)
{ "jsonrpc": "2.0", "id": 42,
  "result": { "subscribed": "folder:abc" } }
{ "jsonrpc": "2.0", "id": 43,
  "result": { "unsubscribed": "folder:abc" } }
{ "jsonrpc": "2.0", "id": 44,
  "result": { "pong": true } }

// s → c (denials — same id, standard JSON-RPC error object)
{ "jsonrpc": "2.0", "id": 42,
  "error": { "code": -32001, "message": "no_read",
             "data": { "topic": "drive:xyz" } } }

// s → c (events — id-less = JSON-RPC notification)
{ "jsonrpc": "2.0", "method": "rt.event",
  "params": {
    "topic": "folder:abc",
    "event": "file_created",
    "data":  { "file_id": "…", "name": "notes.md",
               "parent_id": "abc" },
    "actor": { "user_id": "…" },
    "ts":    "2026-09-08T20:12:00Z"
  }}

// s → c (server-initiated eviction — also a notification)
{ "jsonrpc": "2.0", "method": "rt.revoked",
  "params": { "topic": "folder:abc", "reason": "grant_revoked" } }

Yjs binary frames (CRDT — Phase A collab consumer)

[1 byte kind][16 bytes doc_id][payload…]
  0x01 = Yjs update      → collab:{doc_id}
  0x02 = Yjs awareness   → collab:{doc_id}:awareness
  0x03 = Yjs sync-step   → collab:{doc_id}

The WS handler classifies incoming frames by the MessageType (text/binary). Text frames are JSON-RPC; binary frames are Yjs sync protocol routed to CollabSessionService (see docs/plan/markdown-collab.md).

JSON-RPC error codes (stable — never repurpose)

Uses the JSON-RPC 2.0 "server-defined" range -32000 to -32099, per spec (-32700..=-32000 is the reserved-by-spec block; -32000 downward is application-defined).

code message Meaning Audit reason variants
-32001 "no_read" Resource-scoped topic, caller lacks Read (or resource doesn't exist — indistinguishable to caller by design). Anti-enum invariant. no_read, no_such_resource
-32002 "no_share" Resource-scoped topic requiring Share, caller has Read but not Share. Applies to file:{id}:shares (Phase B). no_share
-32003 "no_comment" Resource-scoped topic requiring Comment (file:{id}:comments Phase B). no_comment
-32004 "topic_forbidden" Identity-scoped mismatch OR unknown/malformed topic. Same wire code regardless of whether the target user exists — anti-enum. identity_mismatch, unknown_topic, not_admin
-32005 "sub_limit" Per-connection sub cap hit. sub_limit
-32006 "rate_limited" Subscribe-frame token bucket exhausted. rate_limited
-32007 "no_edit" CRDT edit frame from a caller without Edit. Emitted as a rt.write_denied notification (not tied to a request id). no_edit
-32603 "internal_error" Standard JSON-RPC internal error — server-side failure the client should retry. — (server log)
-32600 "invalid_request" Malformed JSON-RPC envelope (missing method, wrong jsonrpc version). Standard JSON-RPC. bad_envelope
-32601 "method_not_found" Method outside the rt.* allowlist. Standard JSON-RPC. unknown_method
-32602 "invalid_params" Method known but params shape wrong (missing topic, unparseable). Standard JSON-RPC. bad_params

Codes -32001..=-32007 are our application-defined vocabulary; the -326xx range is JSON-RPC's own standard set and we honour it for envelope-level problems. Both are stable — a new denial cause gets a new code, we never repurpose an existing one, per project convention.

Sec-WebSocket-Protocol subprotocol advertisement

Client's WS handshake sends: Sec-WebSocket-Protocol: oxi.rt.v1, authorization.bearer.<jwt>

Server accepts the handshake with Sec-WebSocket-Protocol: oxi.rt.v1 (the bearer half is consumed for auth, not echoed). The v1 gives us a bump-when-we-break contract handle; adding new methods stays backward-compatible under oxi.rt.v1.

Payload discipline

Event payloads are thin facts (IDs + actor + verb). Never full DTOs — client refetches details via REST if it needs them. Keeps the AuthZ surface small (thin payloads can't leak fields the caller couldn't already read via REST for that resource) and makes the pg NOTIFY 8 KB cap a non-issue.

AsyncAPI generation

Mirror OpenAPI's role for the REST surface. The WS surface gets a machine-readable AsyncAPI 3.0 document generated from the same Rust enums the server uses, so the wire contract stays in sync with implementation by construction — no hand-written spec that drifts.

What it documents

  • Server info + subprotocol — oxi.rt.v1 under Sec-WebSocket-Protocol, connect URL, auth mechanisms.
  • Channels — one per topic-kind (folder, file, job, user-notifications, collab, …), parameterized by their id: folder/{folderId}, job/{jobId}, etc.
  • Operations per channel:
    • send — client subscribe / unsubscribe via rt.subscribe / rt.unsubscribe (JSON-RPC request messages).
    • receive — server events via rt.event notifications.
  • Message schemas — the JSON-RPC envelope and one schema per event variant (file_created, folder_created, share_granted, notification, …). Generated via schemars from the same Rust MessageBusEvent enum the server publishes, so the schema is authoritative, not aspirational.
  • Error object shape + code/message catalog — the JSON-RPC error table above becomes an AsyncAPI-declared errors block on the subscribe operation.
  • Binary frame schema — a application/octet-stream message binding for the Yjs sync protocol frames, with a text description of the [kind][doc_id][payload] layout. AsyncAPI schemas can't fully describe the Yjs framing (it's out-of-band from the JSON envelope), so we document the structure in prose alongside a placeholder schema — same tradeoff every WS spec makes with binary bodies.

Generator — cargo run --bin generate-asyncapi

Follows the same shape as generate-openapi:

  • New binary src/bin/generate_asyncapi.rs that constructs the spec from Topic, MessageBusEvent, AuthzCheck, and the JSON-RPC method/error tables — all live in application/ports/message_bus_ports.rs as the single source of truth.
  • Uses schemars for JSON Schema of each event variant (already compatible with serde derives; no re-annotation needed).
  • Emits resources/gen/asyncapi.yaml (YAML for human-diffability, same choice AsyncAPI tooling defaults to).
  • Add just asyncapi recipe alongside just openapi.
  • CI check: same as the OpenAPI check — regenerate on every build, fail if the working tree is dirty after regeneration. Keeps spec and code from drifting.

Consumers

  • Docs site — AsyncAPI has a first-class HTML renderer (@asyncapi/html-template or the Studio playground). Point the docs at resources/gen/asyncapi.yaml and the WS surface has the same discoverability as openapi.json.
  • Client SDK generation (later) — @asyncapi/generator produces typed clients (TS, Go, Python, Java). Not needed for v1, but the door is open when a third-party integration asks for one.
  • Contract testing (later) — the spec doubles as a contract the smoke tests can assert against; rt-hurl-helper could validate incoming events against the schema before asserting on values. Cheap follow-up.

Scope for the first PR

  • Generator produces spec covering the Phase-A-MVP surface only (rt.subscribe / rt.unsubscribe / rt.ping methods, rt.event notification, Folder(id) and UserAuthz(u) topics, FileCreated / FolderCreated events, the error-code table, defaultContentType, securitySchemes.bearerAuth, and a ping operation with the rt.pong reply shape).
  • Adding a new topic/event/method later is an enum variant + serde derive → regenerate → commit. Same discipline as OpenAPI.

AsyncAPI follow-ups (deferred)

Land with their producer PRs; each is a small addition to generate-asyncapi.rs alongside the code that emits it.

  • rt.revoked notification on the Folder + File channels — the server-initiated eviction frame fired when a grant is revoked mid-session (project-message-bus AuthZ eviction section). Ships with the AuthzChanged publish hook in ShareService::revoke. Wire shape is already fixed by the plan; the AsyncAPI additions are a RtRevokedNotification message + a receive-action operation on every resource-scoped channel that supports eviction.
  • Yjs binary frames — prose section at the doc level: AsyncAPI schemas can't fully describe the [kind][doc_id][payload] framing (it's out-of-band from the JSON envelope), so a plain-English section on the Collab channel description referring to docs/plan/markdown-collab.md § Wire protocol is the pragmatic documentation. Ships with the Collab channel definition when the editor PR lands.
  • Server variable expansion — add a port variable so local dev URLs (ws://localhost:8086/api/rt/ws) can be expressed in tooling that reads the AsyncAPI URL template. Trivial addition; not blocking.
  • defaultMessages per channel — AsyncAPI convention for reducing per-operation $ref boilerplate as the message count grows. Worth introducing once we hit ~10 messages per channel; MVP has 6 on Folder, still legible.
  • Bindings on messages — declare bindings.ws.headers on the subscribe messages so tools can render the auth header shape (the spec knows about it via securitySchemes, but per-message bindings make it explicit at the point of use).
  • Reply message discrimination — the receiveFolderEvent operation could split into per-event-kind messages (RtFileCreatedEvent, RtFolderCreatedEvent) instead of one polymorphic RtFolderEventNotification with oneOf. Better codegen for typed clients. Refactor when we generate an FE SDK.

TypeScript client codegen via @asyncapi/modelina (Phase-A polish)

AsyncAPI has the same "spec → typed FE SDK" story OpenAPI has. Wire it once, avoid hand-maintaining a growing catalog of message types.

  • Tool: @asyncapi/modelina — the AsyncAPI-native model generator. Reads resources/gen/asyncapi.json, emits TypeScript interfaces + tagged unions for every message and schema. Actively maintained, produces idiomatic TS.
  • Not @asyncapi/generator's WebSocket TEMPLATE — that generates a full client SDK on assumptions (fetch shape, subscription model) that don't match our useTopic singleton store. Custom composable stays; only the message DTOs come from codegen.
  • Wiring:
    • frontend/package.json dev-dep: @asyncapi/modelina.
    • Script frontend/scripts/gen-message-bus-types.mjs invokes Modelina, writes to frontend/src/lib/generated/message-bus/.
    • just asyncapi-ts recipe alongside just asyncapi.
    • CI dirty-tree check — regenerate on every build, fail if git diff on the generated folder is non-empty. Same discipline as OpenAPI's check.
    • Generated files carry a // AUTO-GENERATED — do not edit; run just asyncapi-ts to regenerate header.
  • What the FE gets:
    • type RtEvent = FileCreatedData | FileRenamedData | ... — a tagged union keyed on the event discriminator, so the folder view's switch (evt.event) is exhaustive at compile time.
    • RtSubscribeRequestBody, RtErrorResponseBody, error-code enum, RtPongResponseBody.result.pong === true narrowed by type.
    • No divergence between wire spec and FE types — the CI check catches drift.
  • Also worth: if we ever want a typed WS client for other languages (Rust sync client, Python integration), the AsyncAPI spec is the source; Modelina supports 8+ target languages.
  • Timing: the current spec covers 8 event variants + 6 message envelopes. Marginal savings today; substantial as Phase B adds ~15 more event variants (comments, mentions, presence, share events). Set up now so the discipline is in place BEFORE the surface grows.

AuthZ model (audit rules per AGENTS.md)

The subscribe gate

"At least Read on the resource" is the default for resource-scoped topics, but not the whole story. Every topic variant declares its own gate via Topic::required_perm() -> AuthzCheck. Three classes exist — the WS handler dispatches on the returned enum, it does not assume a single check applies everywhere.

Class 1 — Resource-scoped (majority)

Default gate: AuthorizationEngine::require(caller, resource, Read).

Topic Resource Permission
folder:{id} folder Read
folder:{id}:presence folder Read
file:{id} file Read
file:{id}:presence file Read
collab:{file_id} file Read (Reader = view + own cursor; edits gate separately, see below)
collab:{file_id}:awareness file Read
drive:{id} drive Read (drive membership)
calendar:{id} calendar Read
addressbook:{id} address book Read

Two Phase-B resource topics use a stricter permission because the topic itself would leak enumeration metadata a Reader can't otherwise see today:

Topic Actual permission Why not Read
file:{id}:shares Share (Owner-tier) Reader sees the file's content, not who else has access. The share list is management metadata; the REST share endpoints already gate this way.
file:{id}:comments Whatever the REST comments API decides — Read if comments are public to Readers; Comment if commenter-tier only Consistency with REST. The bus does not invent a new policy.

Class 2 — Identity-scoped

Gate: caller_id == subject_uuid. Plain equality. No admin bypass — an admin cannot subscribe to user:{other}:notifications. Privacy is a hard rule; cross-user monitoring uses admin topics, never a user's private stream.

Topic Gate
user:{u}:notifications caller == u
user:{u}:authz caller == u
user:{u}:sessions caller == u
user:{u}:uploads caller == u
user:{u}:trash caller == u

Auto-subscribed topics (user:{caller}:*) at connect go through the same check for consistency — the caller identity is derived from the validated ticket, so this is by construction, but the code path must not short-circuit.

Class 3 — Role-scoped

Gate: caller.role == Admin (or specific admin sub-role once we introduce them).

Topic Gate
admin:sessions admin role
admin:audit admin role

One non-resource topic — bespoke check

Topic Gate
job:{id} jobs.created_by == caller OR admin role. Jobs are not in the AuthZ engine's resource set; the check lives in Topic::required_perm() and queries the job registry.

Enforcement rules

  1. AuthZ at subscribe time, not per event. Fan-out is hot; subscribe is the choke point. Checking every event against every subscriber's grants would burn CPU on busy topics.
  2. Evict on grant loss — do NOT keep re-checking to preserve a sub. The write path publishes AuthzChanged { affected } to user:{u}:authz; the WS handler walks that session's HashSet<Topic> and drops any sub whose resource intersects affected. Same eviction path for resource-delete, group-member removal, and admin kicks.
  3. Anti-enumeration on denials. Per the graduated-denial convention (see authz_require_graduated_denial), the wire reason collapses cases the caller cannot distinguish; the audit line records the truth.
  4. CRDT edit frames re-check on the write side. A Reader can hold a collab:{file} sub (view + cursor); their 0x01 update frames are dropped by the WS handler with collab.write_denied audit (reason = no_edit). Verified once per session and re-verified on user:{caller}:authz events.

Wire-reason vocabulary (stable — never repurpose)

The wire uses JSON-RPC 2.0 error objects — see § Wire protocol → JSON-RPC error codes for the full code/message/audit-reason mapping. That table is the authoritative one; this section cross-references its audit-reason column for the AuthZ dispatch and confirms the anti-enumeration collapse rules the wire honours.

Audit-line convention

  • Connect reject — event = "auth.rt_ticket_rejected", reason ∈ {expired, unknown, ip_mismatch, replay}.
  • Subscribe deny — event = "message_bus.subscribe_denied", reason from the audit column above, plus caller_id, topic. Emitted BEFORE the wire denied frame.
  • Evict — event = "message_bus.subscription_evicted", reason ∈ {grant_revoked, resource_deleted, admin_kick, group_membership_lost}, plus caller_id, topic.
  • Collab edit rejected — event = "collab.write_denied", reason ∈ {no_edit, session_evicted, external_write_conflict}.
  • Notification fanout truncated — event = "notification.fanout_truncated", reason = "over_max_fanout", resource_id, principal, member_count.

Every audit line uses target: "audit" per project convention. Wire reasons are the compressed public vocabulary; audit reasons are the uncompressed private truth.

Limits & backpressure

Limit Default Rationale
Subs per connection 128 Prevents runaway/malicious pinning of server memory
Subscribe frames/sec/conn 50 Token bucket, prevents storm-subscribing
Outbound mpsc slots/conn 512 Full → close WS 1013
Broadcast ring slots/topic 256 Slow subscriber → lag → close WS + audit
Max event size 8 KB Fail-fast dev assertion; keeps pg NOTIFY cap a non-issue
Ticket TTL 30 s Short window, one-shot
max_notification_fanout 1000 recipients / event Beyond this, drop per-user notifications + audit notification.fanout_truncated; the file:{id}:shares event still fires. Covers the "all-employees" group case where individual bell pings would be noise.

Failure modes

  • WS drop mid-session → client reconnects, ticket flow again, re-subscribes. Server discards session state.
  • Publish under load → broadcast::Sender::send never blocks; slow subs lag out. Never let the publish path stall.
  • Ticket replay → ticket is one-shot in-memory; second use is denied + audit.
  • Post-commit publish failing → log a warning and move on. Do NOT retry into a queue; ephemeral events are best-effort by design.
  • Replicator down (v2+) → local bus keeps working for same-instance subs; log the outage; alert.

First PR — MVP scope and hurl smoke test

The smallest slice that proves fan-out works, topics are isolated, and the AuthZ gate rejects unauthorized subscribes. Everything larger (notifications table, presence, collab) rides on top later.

Scope in

  • MessageBus port + InProcessMessageBus.
  • WS handler at GET /api/rt/ws with subscribe / unsubscribe / ping frames only (no CRDT binary frames yet).
  • Auth: reuse existing auth_middleware — session cookie for browsers OR bearer JWT via Sec-WebSocket-Protocol: oxi.rt.v1, authorization.bearer.<jwt> for programmatic clients. Ticket flow deferred.
  • Topics: Folder(id) (Class 1 — Resource-scoped, Read) and UserAuthz(u) (Class 2 — Identity-scoped, auto-subscribed at connect). No other topics accepted in MVP; parser returns Unknown → denied with reason = topic_forbidden.
  • Events: FileCreated, FolderCreated. Publish hooks added in FolderService::create_folder_with_perms and FileManagementService's file-create path (upload / chunked upload commit — publish AFTER commit only).

Scope out (later PRs, not this one)

  • Delete / rename / move publishes (same pattern, verified after create works).
  • user:{u}:notifications topic, notifications table, bell UI.
  • job:{id} topic, collab:{id} binary frames.
  • Grant-revocation eviction (still enforced structurally via Topic::required_perm at subscribe, but no live evict-on-change wiring — that comes with the AuthzChanged publish hook in a follow-up).
  • Ticket flow, rate limiting on subscribe frames, slow-subscriber metrics.
  • Frontend integration (useTopic, folder-view autorefresh).
  • PgListenReplicator — v2 multi-instance.

Test surface — rt-hurl-helper (follows existing convention)

The api-test suite is entirely HTTP via hurl and cannot drive WebSocket. Precedent for auxiliary Rust binaries exists in opaque-hurl-helper and dpop-hurl-helper (both built with --features test_utils, both invoked from tests/api/run.sh outside the main hurl block). The bus test follows the same pattern.

New binary: src/bin/rt_hurl_helper.rs, gated on test_utils. Sole new crate dependency: tokio-tungstenite — added under [dependencies.tokio-tungstenite] optional = true and pulled in by the test_utils feature so the release binary is unaffected. Never ships in production.

CLI shape:

oxi-rt-hurl-helper <mode> [flags]

  subscribe-and-collect         # runs in background alongside hurl
    --url ws://.../api/rt/ws
    --token JWT                 # bearer, passed via Sec-WebSocket-Protocol
    --subscribe TOPIC           # may repeat
    --expect-events N           # exit 0 when N events arrive
    --timeout DURATION          # overall cap, default 3s
    --output PATH               # write JSON summary on exit

  expect-denied                 # runs synchronously
    --url ws://.../api/rt/ws
    --token JWT
    --subscribe TOPIC
    --reason KEY                # expected denial reason, default: any
    --timeout DURATION          # default 2s

Exit codes: 0 = expectation met, 1 = expectation failed (wrong event, unexpected event, timeout without hitting the target, denied when expecting event, or vice versa), 2 = protocol error / connect failure.

Output JSON schema (for post-mortem assertions in shell):

{
  "subscribed":   ["folder:<uuid-a>"],
  "denied":       [],
  "events":       [ { "topic": "folder:<uuid-a>", "event": "file_created",
                      "data": { "file_id": "…", "name": "…",
                                "parent_id": "<uuid-a>", "actor": "…" },
                      "ts": "2026-…" } ],
  "timed_out":    false,
  "protocol_err": null
}

Coverage — four scenarios, each in the same test file

Orchestrated by a single tests/api/rt_bus_check.sh invoked from tests/api/run.sh after the main hurl block. Follows the refcount_cascade / thumb_import_check patterns already in place.

Scenario 1 — Positive delivery (fan-out works)

setup.hurl:
  - user1 logs in → capture $USER1_TOKEN
  - user1 creates folder A → capture $FOLDER_A

shell:
  rt-hurl-helper subscribe-and-collect \
      --token $USER1_TOKEN --subscribe folder:$FOLDER_A \
      --expect-events 1 --timeout 3s --output /tmp/rt_s1.json &
  sleep 0.3   # give the subscribe frame time to ack

actions.hurl:
  - user1 creates a file in $FOLDER_A

wait rt-hurl-helper

Assertion (jq on /tmp/rt_s1.json):

  • .timed_out == false
  • .events | length == 1
  • .events[0].event == "file_created"
  • .events[0].data.parent_id == $FOLDER_A

Scenario 2 — Topic isolation (no event on unsubscribed folder)

Verifies: a user subscribed only to folder A does NOT receive events for actions in folder B, even when the user has full access to both.

setup.hurl:
  - user1 creates folder B → capture $FOLDER_B  (folder A from S1 reused)

shell:
  rt-hurl-helper subscribe-and-collect \
      --token $USER1_TOKEN --subscribe folder:$FOLDER_A \
      --expect-events 1 --timeout 3s --output /tmp/rt_s2.json &
  sleep 0.3

actions.hurl:
  # First: create a file in B — user1 has full access, but we're
  # not subscribed to B, so nothing should arrive on the helper.
  - user1 creates a file in $FOLDER_B
  # Second: create a file in A — this triggers the helper's exit.
  - user1 creates a file in $FOLDER_A

wait

Assertion:

  • .events | length == 1
  • .events[0].data.parent_id == $FOLDER_A ← NOT B
  • no event with parent_id == $FOLDER_B present

The key invariant this locks in: the server fans out per topic, not per user or per drive. A subscriber to folder:A sees only folder:A events, even for topics they'd have permission to subscribe to but didn't.

Scenario 3 — AuthZ denial (subscribe rejected on missing Read)

Verifies: a user without Read on a folder cannot subscribe to its topic. Denial wire reason is no_read; audit line records message_bus.subscribe_denied with reason ∈ {no_read, no_such_resource}.

setup.hurl:
  - user2 registers and logs in → capture $USER2_TOKEN
  - (user2 has no grant on $FOLDER_A, which is user1's private folder)

shell:
  rt-hurl-helper expect-denied \
      --token $USER2_TOKEN --subscribe folder:$FOLDER_A \
      --reason no_read --timeout 2s
  # exit 0 = denied frame received with reason=no_read

Assertion is the helper's exit code (0 pass, 1 fail). No /tmp output file needed for a binary pass/fail.

Scenario 4 — Anti-enumeration parity (nonexistent folder ≡ no access, from the caller's POV)

Verifies: subscribing to a folder that does not exist returns the same wire reason as subscribing to a folder the caller can't Read. Protects against a folder-enumeration oracle.

shell:
  rt-hurl-helper expect-denied \
      --token $USER2_TOKEN --subscribe folder:00000000-0000-0000-0000-000000000000 \
      --reason no_read --timeout 2s
  # exit 0 = same wire reason as scenario 3

Assertion: exit code 0. The audit line (checked out-of-band if we wire log capture) records reason = "no_such_resource" — but the wire reason is no_read, matching scenario 3. This is the graduated-denial invariant from authz_require_graduated_denial.

How the scenarios chain

All four run in one shell script, one WS connection is opened per scenario for isolation (a helper invocation = a fresh WS). No state carries between scenarios except the folder ids and tokens captured in setup.hurl. Total wall-clock ≤ 10 s including sleeps.

Cleanup

Follows the existing api-test convention (per project memory api_tests):

  • Shared DB is dropped between full test-suite runs by tests/common/stop-db.sh.
  • Storage is wiped at run start.
  • No per-scenario teardown; folders A and B persist for the rest of the run — no test that runs after this cares about them.

justfile / CI hook

Add to the existing test-api recipe list of Rust helper builds (there's already a compile step for opaque-hurl-helper / dpop-hurl-helper); the new binary joins the same --features test_utils build. tests/api/run.sh gets one line — ./rt_bus_check.sh || die "rt bus smoke failed" — inserted after the main hurl block, before the existing storage-cleanup / thumb checks.

What this coverage locks in

  • Subscribe path AuthZ gate is real (S3, S4).
  • Anti-enumeration parity between "no perm" and "no resource" (S4) — the invariant the plan promises.
  • Fan-out is topic-scoped, not user-scoped (S2).
  • Publish-after-commit produces exactly one event per action (S1), not zero (rollback lost the publish) and not multiple (retry / double-hook).
  • End-to-end wire format is stable (S1 asserts on event = "file_created" string).

Everything else in the plan — evict-on-revoke, slow-subscriber kick, rate limiting, ticket flow, PgListen replicator — is follow-up test work with its own scenarios, layered on top of this baseline once the baseline is green.


Roadmap

Phase A — Foundation (bus + notifications + MD collab)

Ships the infrastructure and the two most visible consumers together.

  • ✅ Bus port + InProcessMessageBus + NoopReplicator + WS handler + ticket endpoint (F).
  • ✅ Frontend singleton + useTopic + useFolderTopic + useReconnect composables. oxi:message-bus logger namespace.
  • Topics live today: folder:{id}, user:{u}:authz.
  • Topics reserved but not producing: user:{u}:notifications, job:{id}, collab:{file_id}, collab:{file_id}:awareness — land with their consumers below.
  • ✅ Folder-live updates: FolderService / FileUploadService / FileManagementService / TrashService publish file_created / renamed / moved / deleted and folder_created / renamed / moved / deleted after commit; FE folder view refetches on receipt (100 ms coalesce, idempotent). Multi-user + multi-tab verified.
  • ✅ Grant-revocation eviction (Slice C): AuthzChanged → per-topic rt.revoked; folder view toasts + navigates to /files. Session survives; unrelated subs unaffected.
  • ✅ Refetch-on-reconnect: messageBus.onReconnect(cb) → useReconnect composable → folder view refetches after WS comes back. Bridges the in-memory-bus "events lost during outage" gap (see project_message_bus_reconnect_gap memory).
  • Job dashboard live — TODO. JobRegistry publishes step progress and terminal state; FE job dashboard subscribes and replaces polling.
  • Notifications table + bell — TODO (Slice E). New notifications table + NotificationService port; initial ingesters for share-granted, new-login-from-new-device, job-completed-for-you, storage-quota-threshold. FE bell with unread count, slide-out panel, toast pop on receive. Auto-subscribe to user:{u}:notifications server-side, same pattern as user:{u}:authz today.
  • MD collab editor — TODO. See companion plan docs/plan/markdown-collab.md. Depends on binary-frame routing which this plan sketches but doesn't ship (rt_ws.rs today drops binary frames with a debug log).

Deliverables sized ~4 weeks end-to-end. Slice D (folder-live) and Slice F (ticket flow) landed 2026-09-11. Slices E + collab are the open work in Phase A.

Phase B — Presence + comments

Everything that turns OxiCloud from a file store into a shared workspace.

  • Presence topics — folder:{id}:presence, file:{id}:presence. Awareness-style: joined/left/cursor. Ephemeral, not persisted.
  • FE presence UI: "N people viewing" badge in folder header; avatar rail; hover to highlight; "someone is previewing this photo right now" in the lightbox.
  • Comments on any file — new comments table (threaded, per file, supports reactions), CommentService port, file:{id}:comments topic for live delivery.
  • @mentions: mention autocomplete in the comment editor; mention → notification into the mentioned user's user:{u}:notifications topic + notifications row + optional email (reuses existing MagicLinkMailer-style templating).
  • Reactions: 👍❤️🎉 on comments and on files themselves; live fan-out on the same file:{id}:comments topic.
  • Comment resolutions: Google-Docs-style thread markers.
  • NotificationService consumes bus events — up to Phase A the bus's publish calls sit inline in each mutation site (FolderService::create_folder_with_perms, FileUploadService::upload_file_streaming, and — once folder-live rounds out — the delete / rename / move sites for both files and folders). That is the right shape and stays: the bus is location-keyed (Topic::Folder(id), subscriber-scoped) and belongs at the mutation site. When Phase B ships, notifications sit on the same axis (also location + actor + subscriber-driven) — not the FileLifecycleHook axis (which is server-internal, content-keyed, fan-out-to-all). So NotificationService becomes an in-process subscriber to the bus itself: it registers a bus.subscribe(...) on the topics it cares about (folder:{id}, file:{id}, share-grant events), translates relevant events into notif.notifications rows, and re-publishes on user:{u}:notifications. No new dispatcher, no new hook trait, no changes to existing mutation sites — the bus IS the mutation-event pipeline for anything subscriber-driven. Contrast with FileLifecycleHook (src/application/ports/file_lifecycle.rs): that stays focused on content transitions (blob_hash, content_type) and fires unconditionally to server-side workers (thumbnails, audio metadata, plugins). Bus and lifecycle-hook are complementary — same triggering moment, orthogonal fan-out shape and payload discipline. Do NOT try to unify them; the two axes are genuinely different (all-vs-subscribed × content-vs-location).

Deliverables sized ~3 weeks after Phase A.

Phase C — Sync client push + album live

Where the bus starts paying for itself on infrastructure cost too.

  • Sync-client push invalidation: WebDAV / NextCloud DAV handlers publish file:{id} and folder:{id} deltas after commit. Sync clients get a lightweight Sync-Invalidate mechanism (or a dedicated WS endpoint for headless clients) so they refetch only changed paths instead of polling PROPFIND. Cuts a large chunk of Nextcloud-style client chatter.
  • Album live updates: folder:{album_id} reused — as photos are added to an album, everyone viewing sees them appear.
  • Slideshow sync: one presenter picks "Present"; other viewers of the album can opt-in to follow the presenter's current frame. Uses folder:{album_id} with a presenter_frame event kind.

Deliverables sized ~2–3 weeks after Phase B.

Later — multi-instance & broker

Only invoked when the deployment actually needs it. Nothing above depends on these landing on any fixed date.

  • PgListenReplicator — ship when we run more than one server instance. Same port, no consumer changes.
  • BrokerReplicator for RabbitMQ or NATS — ship when either cross-datacenter fan-out or a shared broker with other services matters. Same port, no consumer changes.

What this bus does NOT replace

  • Message queue / job queue — jobs stay in job_registry; bus just carries their progress live.
  • Audit log — stays tracing target: "audit".
  • Email — NotificationService's deliverer for offline users.
  • Durable per-user "inbox" — the notifications table is the source of truth; bus is the live-delivery optimization.