Skip to content

Matrix Integration Topology & Gaps

This page captures an independent architecture review of the Drupal–Matrix chat integration (workstream lead: John Locke — see Matrix for module status). It’s derived from reading the entity-relationship diagram already on this site plus the upstream matrix_api module’s own integration overview. It is commentary and analysis, not a spec — treat it as a reference for questions to raise with the workstream lead, not as a description of agreed scope.

flowchart TB
  subgraph matrix["Matrix side (self-hosted, admin-controlled)"]
    client["Matrix clients"]
    synapse[("Synapse homeserver<br/>own Postgres DB<br/>full event history")]
    client -->|"Client-Server API"| synapse
  end

  subgraph drupalside["Drupal side"]
    as["matrix_api_application_server<br/>(AS endpoints, txn dedup)"]
    sub["matrix_api_entity<br/>MatrixEventSubscriber"]
    filter{"Thread reply to a<br/>known Drupal node?"}
    inbound["InboundSyncService"]
    outbound["OutboundSyncService"]
    eca["ECA events / conditions / actions"]
    idmod["matrix_api_identity"]
    idp[("External IDP<br/>Keycloak / Simple_OAuth")]
    db[("Drupal database<br/>matrix_event_map<br/>matrix_identity_map<br/>comments / nodes")]
    drop(["Dropped —<br/>never persisted to Drupal"])
  end

  synapse -->|"PUT /_matrix/app/v1/transactions/{txnId}<br/>(synchronous, one request per transaction)"| as
  as --> sub
  sub --> filter
  filter -->|"No"| drop
  filter -->|"Yes"| inbound
  inbound -->|"writes"| db
  sub -.->|"fires event"| eca
  db -.->|"hook_node_insert/update,<br/>hook_comment_insert"| outbound
  outbound -->|"sendMessage / sendThreadReply / redactEvent"| synapse
  idmod <-->|"account linking"| idp
  idmod -->|"writes"| db

The full Matrix event history lives in Synapse’s own Postgres database — that’s by design (the ERD’s implementation-status table marks MATRIX_EVENT and MATRIX_USER “External,” not Drupal-stored). Only events that resolve to a thread reply under a Drupal node pass MatrixEventSubscriber’s filter and get written into Drupal (matrix_event_map, plus a real Drupal comment entity). Everything else — ambient chatter, DMs, any room with no Drupal-side content anchor — is inspected and discarded in the same request; it never touches the Drupal database.

Stripping out what’s just the Matrix protocol doing its job (federation, spaces, threading, room versioning, Synapse itself — all vendored, not invented), the actual original contribution is the Drupal-to-Matrix mapping layer:

  1. An Application Service implemented inside the CMS, not as a standalone bridge. Most Matrix integrations (Slack, IRC bridges) run as separate microservices using matrix-appservice-bridge in Go/Python/Node. matrix_api_application_server embeds the AS directly in Drupal/PHP — an unusual, CMS-native architectural bet.
  2. Tiered inbound transports for varying levels of homeserver access. ApplicationServer / CronPoller / DrushDaemon degrade gracefully depending on whether you administer the Synapse instance or only hold a bot account on someone else’s. Most bridges assume AS registration rights and skip the fallback tiers entirely.
  3. Two-way, thread-preserving content sync mapped onto Drupal’s actual entity model. m.replace to update a message while preserving the original event_id as thread root; m.thread/m.in_reply_to for comment replies; the polymorphic matrix_event_map index (entity_type + entity_id → event_id + room_id) resolving both directions. Generic bridges post one-way status updates; this is round-trip sync shaped specifically to Drupal’s node/comment hierarchy.
  4. ECA exposure — business rules as configuration, not PHP. Matrix events and conditions (“is this a thread reply?”, “is the sender linked to a Drupal account?”) become ECA primitives, so site builders wire behavior without code. This only makes sense inside the Drupal ecosystem.
  5. Group/Space hierarchy mapping. Drupal Groups → Matrix Spaces → Rooms, keyed off a generic drupal_entity_type/drupal_entity_id polymorphic attachment, so a Space or Room can represent a Group, node, or taxonomy term without coupling to a specific implementation.

What’s not novel: Synapse, the AS spec, m.space.child nesting, room v12 restricted joins, OIDC account linking as a Matrix capability — all pre-existing protocol/homeserver features being consumed as-is.

  • The cited implementation-details spec doesn’t exist. integration-overview.md points to /docs/superpowers/specs/2026-06-18-matrix-drupal-integration-design.md for implementation details. That file is not in the matrix_api repo’s 3.0.x tree — only an unrelated Key-module-integration spec exists there. The doc that would answer the harder architecture questions is currently a dead link.
  • No write-behind / queueing for inbound events. Synapse pushes an AS transaction and matrix_api_application_server “dispatches each event synchronously” (the overview doc’s own words) — inline, in the same request/response Synapse is waiting on, with no Drupal Queue API item and no parallel drain workers. The de facto “queue” is Synapse’s own retry/backoff on a slow or erroring AS endpoint, which is the opposite of a queue that drains quickly once it engages.
  • matrix_event_map.event_id is only indexed, not unique. Loop-prevention depends on this; under concurrent or retried transactions (which Matrix federation does routinely), a non-unique index allows a race to create two map rows for the same event.
  • Cross-entity-type uniqueness has no DB-level enforcement. room_id must be globally unique across both MATRIX_ROOM and MATRIX_SPACE (spaces are rooms too), but these are two separate Drupal entity types — Drupal has no native cross-table unique constraint for this, so it needs an explicit app-level validation check that isn’t yet specified.
  • PROCESSED_TRANSACTION’s expiry isn’t confirmed to be reaped. The dedup table has an expires_at column, which is the right shape to bound its growth — but a TTL column with no cron/queue worker actually deleting expired rows is equivalent to no TTL at all.
  • No homeserver capacity plan for concurrent scale. Neither doc names a homeserver implementation choice, version, worker/sharding topology, or database tuning for a given concurrent-user target. Synapse at real scale needs horizontally-scaled worker mode plus a tuned Postgres — that’s out of scope for both docs as they stand.
  • Drupal is not a mirror of Matrix activity. By design, only the content-linked subset (thread roots/replies matched to existing Drupal nodes) ever reaches the Drupal database. If any consuming service expects Drupal’s database to hold a queryable copy of all Matrix activity, that is not what this architecture provides — worth confirming as a scoping question, not assuming as a gap to fix.

These are open questions to raise with the workstream lead, not defects in what’s shipped so far — the modules implemented to date (matrix_event_map, matrix_identity_map, declared Matrix ID, application-service settings, PROCESSED_TRANSACTION) match what the entity-relationship diagram describes as “Implemented.”