Connect Inbox -- developer guide
The Inbox is where a practitioner sees the things addressed to them across every facility they work at: a referral from a colleague, a lab result against an order they placed, a message from another practitioner. Issue #2449, PR #2463.
It is not a chat. Every message is produced by something that happened, and a message is an ordered array of typed blocks rather than a string. Read that sentence again before changing anything here -- most of the design follows from it.
| Half | Where | Read first |
|---|---|---|
| Backend | backend/domains/connect/ | models/inbox_thread.rb, then concerns/inbox_party.rb |
| Frontend | frontend/apps/connect/src/components/inbox/ | frontend/packages/shared-services/src/domains/connect/inbox.hooks.ts |
Other documents: requirements and plan, legacy UI reference, user guide, QA guide.
1. The promises the code keeps
When a change would break one of these, it is the wrong change.
visible_to(reader)is the entire access control. These tables carry no row-level security, so a query that reaches them another way is a cross-tenant leak. There is no second predicate anywhere, and there must not be.- No clinical document enters the Inbox. A
documentblock names a record byreference_typeandreference_idand resolves through that record's own rules. The model rejects alinkon it rather than merely not setting one. - No patient name appears in a produced message, or on the realtime channel. The producers
never read a referral's
reasonornotes-- free text a human wrote routinely names the patient. - An action is re-derived on the server. The client's
{link, method}is compared and discarded; a symbol derived from the stored message is what dispatches. - A blocked sender cannot tell they were blocked. The block is checked after the per-party rule, so the refusal is whatever that pairing already emits.
2. Why there is no RLS here
Connect::BaseController#clear_tenant_context! empties the app.current_tenant_id GUC on every
Connect request, because Connect reads across every tenant a practitioner belongs to. A
tenant_isolation policy on a connect.* table would therefore return zero rows to every
reader, not the right rows.
So connect.* and notifications.* carry no RLS, by design, and scoping is application-layer
only. That is the single most important fact about this code, and the reason promise 1 is written
the way it is.
Two consequences worth knowing:
- A FORCE-RLS table read from here returns zero rows; it does not raise.
Clinical::Patientis FORCE-RLS, so a patient counterparty resolves toniland renders without a name. Do not wrap such a read in a rescue and call the exception "expected" -- it cannot happen, and the rescue will then hide a real fault instead (this was a review finding). - Cross-tenant scoping tests are load-bearing. Every list endpoint needs one asserting another practitioner's rows are invisible. With no RLS backstop, that assertion is the only thing between a bug and a leak.
3. Data model
Six tables in the connect schema. Migrations 20260920040000, 050000, 060000, 070000,
plus 080000 which adds a policy to clinical.referrals.
| Table | Holds |
|---|---|
inbox_threads | One row per counterparty pair. party_a_* / party_b_* are canonically ordered, so A-with-B and B-with-A are one row. |
inbox_messages | The messages. message_payload is a jsonb array of blocks. Five attachment_* columns. |
inbox_sender_blocks | Directional rows; the predicate is symmetric. |
inbox_reports | A whole moderation ticket: status, assignee, resolution, and an evidence snapshot. |
pharma_opt_ins | Current state, one active grant per practitioner-vendor pair. |
pharma_opt_in_events | The immutable audit trail behind it. |
A thread was never an entity before
The legacy implementation computed one with DISTINCT ON (sender_id, sender_type) at query time.
Here it is a real row, which is what makes unread counts, blocking and a stable sort possible.
Canonical ordering is enforced twice: before_validation :canonicalise_parties in the model, and
a CHECK constraint with COLLATE "C" in the database. InboxParty.sort_key must agree with that
CHECK, including its nil-UUID sentinel for the 'System' party. The unique index COALESCEs both
party ids and facility_id to that same sentinel.
Blocks
message_payload is ordered and typed: from, title, text, html, document, action.
Only from, title and text may be composed by a person. html, document and action
are producer-only, and the compose services refuse them. An unknown block type is stored
unvalidated on purpose, so a newer producer does not need a schema change -- but that also means
R22a is enforced for the exact key link, not for every URL-shaped key. Treat a new block type as
a contract change.
4. Endpoints
GET /connect/me/inbox threads, per counterparty
GET /connect/me/inbox/:thread_id one thread, oldest first; marks read (see below)
POST /connect/me/inbox/threads open a thread
POST /connect/me/inbox/threads/:thread_id/messages reply
POST /connect/me/inbox/actions run one action block
GET /connect/me/inbox/messages/:message_id/attachment stream a file
GET /connect/me/inbox/blocks the reader's own blocks
POST /connect/me/inbox/blocks block
DELETE /connect/me/inbox/blocks unblock -- NO row id
POST /connect/me/inbox/reports report a message
GET /connect/admin/inbox_reports[/:id] platform admin only
PATCH /connect/admin/inbox_reports/:id assign, resolve, reopenReading a thread marks the served page read. With newest_first=true, page 1 also marks every
older unread message in the thread, up to the newest message it served. The client opens a thread
newest first and fetches older pages only on scroll, so without this the unread count of a thread
longer than one page never reached zero. Other pages mark only themselves.
Route order matters: /blocks is declared above get 'inbox/:thread_id', which would
otherwise swallow it as a thread whose id is the string "blocks". There is a test for it.
The Inbox sits on Connect::PersonalEndpointBase, which writes one Audit::AdminAccessLog row
per request (HIPAA 164.312(b)). That log records the keys of a write, not its values -- see
§8.
5. Where the messages come from
Clinical::ReferralReceivedNotificationJob ─┐
Clinical::ReferralStatusNotificationJob ─┴─> Connect::InboxReferralMessageJob
└─> InboxProducers::ReferralProducer
Clinical::DiagnosticReport after_commit ──────> Clinical::ResultLandedNotificationJob
└─> Clinical::Results::LandedNotifier
Practitioners::ProfileCreationService (#2464) ─> Connect::InboxWelcomeMessageJob
└─> InboxProducers::WelcomeProducerevent_type holds the record's Rails class name -- Clinical::Referral,
Clinical::DiagnosticReport, Identity::Practitioner. Not a demodulized word. RouteDeriver
once matched 'referral' against a stored 'Clinical::Referral', which made every Accept answer
501 and left the whole cross-tenant accept unreachable; producer_dispatcher_integration_test.rb
exists so that cannot recur, and it deliberately takes no event_type argument.
The bell (notifications.notifications) and the Inbox are separate stores. One referral writes
a row in each. Do not try to derive one from the other.
6. The action dispatcher
InboxActions::Dispatcher#call does four things in order:
- load the message through
visible_to(404 otherwise); - derive the permitted actions from the stored payload;
- compare the client's asserted
{link, method}against them as whole strings; - dispatch the derived symbol, never a string off the wire.
An action this build cannot map answers a sentence ("this version of Kulcare cannot run it yet"),
not a 500. If you see that sentence for a referral, step 2 and the producer disagree about
event_type.
7. Cross-tenant referral accept
The case: a referee who holds no membership in the sending tenant -- the independent
specialist the cross-tenant read was built for -- could see a referral and not accept it, because
clinical.referrals is FORCE-RLS and referral_referee_read is SELECT-only.
Migration 20260920080000 adds a FOR UPDATE policy keyed on the referee GUC. The policy alone
is not enough, because RLS restricts rows and not columns, and a column privilege is invisible
both to the table owner and to any role that already holds table-wide UPDATE -- which the app role
does, and must keep, since the sender's own tenant edits reason, notes and priority through
the ordinary update path.
So the accept assumes a second, deliberately impoverished role for the length of one statement:
kulcare_referral_referee
NOSUPERUSER NOBYPASSRLS NOLOGIN NOINHERIT NOCREATEDB NOCREATEROLE
GRANT SELECT ON clinical.referrals
GRANT UPDATE (status, accepted_at, completed_at, updated_at)
GRANT USAGE ON SCHEMA clinical
-- and nothing else, anywhereRlsContext.with_referee_write assumes it and restores the previous role. The policy carries no
TO clause -- pg_dump would write it into structure.sql verbatim and never emit the
CREATE ROLE, so the file would fail to load. The narrowing lives inside the USING expression as
CURRENT_USER = 'kulcare_referral_referee'.
Neither the role nor its grants can live in structure.sql (pg_dump -x).
db:referral_referee_role:provision creates them, db:rls_test_role:provision calls it, and
test_helper.rb's parallelize_setup provisions it per worker database -- without that, 19
write-security tests skip, and bin/ci-test-domains counts only failures and errors, so
skipping reads as green.
8. Attachments
has_one_attached is not used, and that is not a style choice:
public.active_storage_attachments.record_id is a bigint while every domains/ model has a
uuid primary key, so the join row stores record_id = 0 and records of a type collide. That
defect is issue #2467 and is latent in
five other models today. The Inbox owns five columns on its own table and addresses bytes by its
own key, while still storing through a registered ActiveStorage service.
- No blob URL is ever emitted. ActiveStorage is mounted at
/with no authentication, so a signed blob id is a bearer capability.Connect::InboxAttachmentsControllerauthorises the reader as a party and then streams. Holding the URL is not authorisation, and a non-party gets 404 rather than 403. - The content type comes from the bytes, via Marcel, not from what the client declared. SVG is deliberately absent from the allowlist. 10 MB, one file per message.
- Only a composed message may carry a file, where composed means the reserved category and a
blank
event_type/event_id, so a producer cannot claim it.
Retention is two ages -- 2 years for a message, 1 year for its file, because the file is the
personal-data-dense object while the surrounding text is administrative correspondence.
Connect::InboxRetentionJob does the sweep each night at 21:30 UTC, from config/schedule.rb
(#2468). The sweep also clears the
evidence snapshot of a report older than 2 years. Account closure clears the snapshot of each report
that touches the closed party, and erases the correspondence of the practitioner, vendor and patient
records of the user. A file whose delete fails keeps its reference, is counted as a failure, and is
tried again the next night. When that file belongs to a message past its age, the message also stays,
and the sweep counts it apart as messages_kept_past_max_age.
One bad row does not stop the sweep. Each row (message, attachment purge, empty thread, report
snapshot) and each step has its own rescue. The night sends one report that names every failure. A
message kept past its age makes that report an error, not a warning. When a row or a step failed,
the sweep raises Connect::InboxRetention::SweepFailed at the end, so the job fails in Sidekiq. The
job has no Sidekiq retry (the next night runs again). A file delete failure alone does not raise. The evidence prune skips a
snapshot whose messages is not an array; the 2-year report rule erases it later. It drops a copied
message with a missing or malformed created_at as old.
The audit log is not a copy of the correspondence
PersonalEndpointBase redacts by allowlist on a non-GET request: identifiers and pagination
knobs keep their values, everything else is recorded as its key with the value redacted. A GET
keeps its values.
This matters because audit.admin_access_logs sits outside every control the Inbox builds -- it
is cross-tenant, readable by any platform admin, and reached by neither InboxRetention nor the
account-closure erase. A body copied there outlives the message it came from. The previous
blocklist of eight credential keys let message bodies, report details and attachment filenames
through the moment this controller first served a POST.
9. Realtime
Connect::InboxMessage carries after_commit :publish_realtime_signal, on: :create. The hook is
on the model, not in each producer: every write path goes through it, and a producer that
forgets to publish is a thread that silently never updates, which review does not catch.
The payload is { id, thread_id } and nothing else -- not even category, which is an open
vocabulary whose values name the producing event and so describe the content. The client treats it
as a refetch signal and re-hits the endpoint, where visible_to is the access control.
useConnectRealtime listens to connect.inbox.message_created and invalidates
connectKeys.inbox.all(), debounced with the notification events (#2380). The Ably
token grants subscribe and deliberately not history.
Shared::RealtimePublisher is the shared publisher. Its raise_on_error: flag makes the failure
posture the caller's choice: a job with retry_on raises (a dropped signal is lost otherwise); a
webhook whose write already committed logs.
10. Frontend
The hooks and keys live in @kulcare/shared-services (inbox.api.ts, inbox.hooks.ts,
connectKeys.inbox), following the feed's precedent. The app holds the components only.
connectKeys.inbox.threads.all()is a prefix of every list and of no thread, so reading a thread invalidates the list without invalidating itself.usableParamsdropsundefined,nulland blank strings only. A falsy-dropping version turnsread=falseinto "the whole inbox" with nothing on screen saying so.runInboxActionreads a refusal envelope back off the axios rejection, because the actions controller renders the same success shape on every outcome. The compose, block and report mutations reject instead, andgetAPIErrorMessagereadserror.messageahead of any status default -- so the server's own words reach the screen.queryPhaseincomponents/direction-b/queryStates.tsxseparates offline, loading, error and the empty states. A paused offline query isisPending && isPaused; do not re-derive this.documentLabelstrips the Rails namespace. Without it the fallback renders "Clinical::Referral" to a doctor.- Both panes are paged.
useInboxThreadPagesloads 20 conversations at a time.useInboxThreadasks for a thread withnewest_first=true, so page 1 is the newest 50 messages, andfetchOlderloads the page before them. It merges the pages oldest first. useInboxUnreadCountreadsmeta.total_unread_countfor the count on the Inbox sidebar entry.
11. Testing
# backend
cd backend
RAILS_ENV=test PARALLEL_WORKERS=1 bundle exec rails test domains/connect/test
# frontend
cd frontend
mise exec node@22 -- pnpm --filter @kulcare/connect exec vitest run
mise exec node@22 -- pnpm --filter @kulcare/shared-services exec vitest runThree traps this suite has already fallen into:
- A missing route also answers 404. A test asserting "a non-party is refused" passes against an endpoint that does not exist. Pair every negative with a positive control in the same test.
- A skipped test reads as green.
kulcare_rls_testis granted table-by-table, so any table created after the last provision makes its tests skip. Re-runRAILS_ENV=test bundle exec rake db:rls_test_role:provisionafter every migration, and treat a non-zero skip count on this suite as a finding. - The local test role bypasses RLS. An assertion outside
rls_enforced!/with_rls_enforced_rolecannot fail. To prove a policy bites, drop it and watch the test go red.
Open test gaps are listed in #2475.
12. Not built yet
| What | Where it stands |
|---|---|
| The S3 bucket | The credential is unset, so staging writes Inbox attachments to local disk. #2468 |
| Pharma opt-in surface | Table, services and audit exist; no controller and no route, so vendor messaging is refused by design until the Connect consent screen lands. |
| Patient-initiated messaging | InitiationRules::PATIENT_INITIATION_SWITCH is off. |