Skip to content

Internal Messaging

Internal messaging is the app-owned conversation substrate for user-to-user, operator-to-user, and support-desk threads. It is not an external integration. External providers such as email, SMS, push, or chat tools are delivery adapters that can react to message events later.

Ownership

Layer Owns
messages module Threads, messages, read state, conversation scope, recipient fan-out events.
workspace_support module First-party Studio support tickets, ticket status, severity, page context, feedback.
support build-context pack Generated app support request metadata and support inbox pages.
social build-context pack Optional friends, follows, invitations, posts, comments, reactions, and activity feed records.
Platform notifications In-app notification persistence and notification center visibility.
Integration modules Optional external delivery through provider-specific adapters.

This separation keeps support from becoming a second chat system. Support records reference message_thread_id; the conversation itself lives in the messages module.

Active Studio Modules

factory_app/app/modules/messages/
factory_app/app/modules/workspace_support/

messages exposes:

Action Purpose
create_thread Create a group or direct thread.
list_threads List direct and group threads for the current participant.
get_thread Fetch a direct or group thread and its messages.
send_message Persist a direct or group message and emit recipient notification facts.
mark_thread_read Store read state for a direct or group thread.

Message threads carry:

Field Meaning
scope_type app for app-owned conversations, workspace for workspace/user social conversations.
scope_id The app id or workspace id for the selected scope.
subject_app_id The app the conversation is about. Support threads should set this even when Studio stores the record in a management persistence scope.

workspace_support creates and links a support thread when a support request is submitted. Operator replies call MessageService.send_message() with the ticket owner as the recipient. The support module still emits domain.workspace_support.message_added for audit and support-specific automation, but user notification is owned by domain.messages.message_sent. The public Studio messages actions exclude threads linked to workspace_support.request, including older app-scoped threads. The support module reads and writes those conversations only after the request lookup is scoped to the verified persistence workspace. Requests without verified ownership remain inaccessible; old workspace_id metadata is not proof.

Generated apps selecting the support pack also select messaging. Their support.create_support_request action creates the ticket's messages thread and first message on the server, then stores the thread id with ticket metadata. The Support page uses support.get_support_conversation and support.reply_support_request; the service checks requester ownership or support read/manage permission before delegating to MessageService. The generated public messages actions serve direct and group conversations only. They cannot create, list, read, or send support threads, even when the caller has generic messaging permission. This keeps the ticket as the authority for support conversation access. Support request records store the subject and page context, while message bodies stay only in the messages collection.

Support status is explicit:

  • open means the ticket can still receive replies.
  • resolved means the ticket is closed; the linked message thread is also marked resolved and the composer should be hidden until the ticket is reopened.
  • "Needs reply" is derived UI state for open tickets where the latest support message came from the user or assistant transcript.
  • "Responded" is derived UI state for open tickets where the latest support message came from an operator. Sending a reply does not resolve the ticket.

Events And Notifications

messages/contracts/events.yaml declares:

  • domain.messages.thread_created
  • domain.messages.message_sent
  • domain.messages.thread_read

messages/contracts/notifications.yaml uses recipient_ids from domain.messages.message_sent to create in-app notifications. This is the canonical path for "operator replied" notifications.

workspace_support/contracts/notifications.yaml owns support-queue alerts that are not plain message-recipient delivery: new support requests, ticket-owner replies, and negative feedback. These rules target principals with workspace_support.read, so a user who is also an admin can see operator alerts when their active principal has that scope. The profile support page remains user-scoped by default and lists only the current user's tickets, including resolved tickets. Admin/support queues must request scope=app or scope=workspace and carry support read or manage permission. Support actions require an authenticated caller when auth is enabled; the service checks ticket ownership for user replies and mutations. Hosted products grant operator permissions through their own validated identity policy. The Studio queue routes also require the admin role for navigation, but that role alone does not grant the module's read or manage permission. An OIDC operator token needs those permissions from its identity policy or a host-owned permission resolver.

The subject_app_id action input identifies the app a request or feedback item is about, or filters an operator queue. It does not change the authenticated runtime app_id or persistence scope. Support clients send their access token with module actions. A failed ticket insert or list read is surfaced as an error, so the UI does not claim that an unsaved ticket exists or show an unavailable queue as empty. Generic message lookups and mutations resolve thread_id inside the caller's current app or workspace scope before loading messages, updating the thread, or writing read state. A matching participant id alone is not enough to read or mutate a conversation from another app/workspace scope. The workspace scope is the caller's verified workspace: the workspace the validated token is bound to, or a membership a host scope hook verified (verified_workspace_id). A workspace named by the request (scope_id, the dispatch context or the query string) never selects it, and a signed-in caller with no verified workspace cannot create, list or open workspace threads. Local development (authentication off, development access) keeps using the dispatch workspace. A dispatch workspace or tenant that conflicts with the verified principal is refused before a thread read, write, or event emission. Generated messaging modules apply this rule through the authenticated persistence principal, including host-verified membership. App conversations remain bound to the loaded app. The generated module permits explicit workspace selection only with local development dispatch authority. The profile support panel groups tickets by subject_app_id, keeping the authenticated runtime app_id separate from the app the ticket concerns. If a listed ticket has no accessible linked message thread, its error field tells the profile and operator panels to show the failure and withhold the reply control. Clients do not submit sender_role; the authenticated module derives the role from server-side authority. Profile page hydration resolves module scope through the same host hook as HTTP module actions, so hosted membership can supply workspace scope and permissions when they are absent from the login token.

Escalation UI should create a support request and navigate users to /me?tab=support-tickets, optionally with request_id and app_id query parameters. It should not send users to a generic /support route, because the profile support tab is the durable user-facing transcript surface.

Persistence

The messages module uses ctx.persistence.collection():

Entity Collection
Threads messages, threads
Messages messages, messages
Read state messages, thread_reads

Support tickets stay under:

Entity Collection
Requests workspace_support, requests
Feedback workspace_support, feedback

Generated social or support-heavy apps should reuse this contract instead of creating module-local message arrays, support-only transcript collections, or provider-specific chat stores.

Modular Selection

  • Select messaging when the app needs persisted conversations.
  • Select support when the app needs a help desk, support inbox, or operator replies. support requires messaging.
  • Select social when the app needs friends, follows, invitations, user posts, comments, reactions, or activity feeds.
  • Keep governance features such as proposals, votes, quorum, moderation policy, and treasury/decision logic app-specific until there is a reusable governance pack. They can reference messages for deliberation threads, but they should not be generated as default social or support behavior.

Generated support conversations are app-scoped. Studio support requests are owned by the verified persistence workspace, while their linked thread records may retain app scope and are accessible only through the request action. Social graph and DMs are not automatically app-scoped: apps that need workspace-level social behavior should use scope_type=workspace for message threads and keep social graph ownership in the social pack.

Hosted Product Extensions

Hosted product workspaces such as mozaiks-app consume this substrate instead of forking it. Hosted modules may add product policy, announcement surfaces, operator routing, realtime fan-out, moderation, billing, marketplace, or community-specific behavior, but the durable conversation state remains:

  • messages owns threads, message bodies, participants, read state, and domain.messages.* lifecycle events.
  • support modules own ticket metadata and reference message_thread_id instead of copying transcripts into a second store.
  • social modules own friends, follows, invitations, posts, and feed records.
  • notification modules consume declared events; they do not become message or support persistence.

Contacts are not a messaging primitive. A hosted or generated app should only keep a contacts module if it has explicit private-address-book semantics that are distinct from friends, follows, or workspace membership. A DM picker roster should be backed by social relationships or a dedicated relationship provider, not messaging.contacts.* capabilities.

Governance remains product-specific until there is a dedicated reusable pack. Hosted community governance should use hosted.* events for proposal, voting, quorum, treasury, moderation, and revenue-policy lifecycle. A future OSS governance pack should be smaller and generic, such as proposals and decisions, and may reference messages for deliberation threads.

External Delivery

In-app notifications are the default OSS delivery path. Email, SMS, push, and workspace chat providers should be added as integration-backed delivery adapters that subscribe to domain.messages.message_sent and respect the same recipient facts. Those adapters should not own thread or message persistence.

Debugging Support Escalation

Use these log filters when verifying widget-to-profile support flows:

  • Browser console: [mozaiks-support] for widget escalation and request creation.
  • Browser console: [mozaiks-profile] for /me tab hydration and active tab sync.
  • Browser console: [mozaiks-support-panel] for profile support ticket selection and replies.
  • Backend logs: workspace_support: for request metadata, thread linkage, and support-message hydration.
  • Backend logs: messages: for thread creation, message persistence, and recipient fan-out.

The profile URL may carry app_id as subject context, but profile panel hydration must remain bound to the active runtime app. /api/me/profile-panels passes that app_id to module actions as contextual input instead of using it as the module execution persistence scope.