Three years ago we made a bet that turned out to define everything else: the platform would not have a separate data warehouse. Every product surface and every analytical surface would query the same store. We called it the work graph. It is the reason a single board can show every renewal in flight, every task blocking it, every doc attached, every meeting scheduled, every contract draft, every signature collected, all in one query.
This post walks through what we built, why we picked it, how it is designed to perform, and where the rough edges still live.
What we actually mean by "graph"
Inside the platform, every record is a node and every relationship is an edge. A task is a node. The project it belongs to is a node. The user assigned to it is a node. The doc linked from its description is a node. The Slack message that created it is a node. None of these live in a separate database. They share one ID space, one schema registry, and one transactional store.
Concretely, a node looks like this in our internal representation:
{
"id": "wrx_task_01HV6PE3X7K2",
"type": "task",
"tenant": "wrx_t_acme",
"v": 247,
"props": {
"title": "Send onboarding deck",
"status": "in_progress",
"due_at": "2026-05-20T17:00:00Z",
"assignee": "wrx_user_01HV5MN1",
"project": "wrx_proj_01HV4ZQ8",
"created_at": "2026-05-12T09:14:22Z"
},
"acl": ["wrx_user_01HV5MN1","wrx_team_growth"],
"tags": ["customer:northwind","stage:proposal"]
} And an edge is a directed pair with an optional label and weight:
{
"tenant": "wrx_t_acme",
"from": "wrx_task_01HV6PE3X7K2",
"to": "wrx_doc_01HV6PG7M9",
"label": "references",
"weight": 1.0,
"via": "wrx_user_01HV5MN1",
"ts": "2026-05-12T09:14:31Z"
} Nothing exotic. The interesting part is what we promised about it: every node and every edge is in one tenant-scoped store, every write is transactional, and every traversal sees a consistent view.
Why not a warehouse, a graph database, or both
The obvious shape for an enterprise platform is a transactional store for the operational data, a warehouse for the analytical data, and an ETL pipeline between them. That shape is well understood. It is also where most products that try to be "all in one" eventually get stuck. The freshness gap between operational data and reporting becomes a feature flag, then a support ticket, then a customer escalation.
We considered three real alternatives:
- Postgres plus a warehouse. Familiar, well-supported, well-understood operationally. The latency between a write in Tasks and a row appearing in a board would be measured in tens of seconds at best. That violated the product premise.
- A native graph database. Neo4j, TigerGraph, JanusGraph. Excellent for traversal-heavy queries. Less excellent for the 80 percent of our workload that is rectangular: list views, sorts, filters, pagination, edits. Also: weak transactional isolation in multi-tenant settings.
- Postgres with graph semantics on top. What we picked. Tables hold nodes and edges, each row also lands in a columnar index for analytical reads, and a single planner serves both shapes.
The trick is the columnar index. Every write to a node table is captured in a change log, and a background worker materializes the most recent snapshot into a column store. The design target is a lag of a second or two, not the tens of seconds a warehouse ETL would impose. The same query planner sees both. Boards that need fresh data read from row storage; boards that aggregate over millions of records read from columns.
Storage layout
Every tenant has its own logical schema. Inside that schema, there are four primary tables and a handful of indexes:
nodes (
id text primary key,
type text,
props jsonb,
acl text[],
tags text[],
v int,
created_at timestamptz,
updated_at timestamptz
)
edges (
tenant text,
from_id text,
to_id text,
label text,
weight float,
via text,
ts timestamptz,
primary key (tenant, from_id, label, to_id)
)
node_log (
ts timestamptz,
node_id text,
v int,
diff jsonb,
by text
)
edge_log (
ts timestamptz,
from_id text,
to_id text,
label text,
op text -- 'add' | 'remove'
) The node_log and edge_log are append-only. They double as the audit log, and as the source-of-truth stream for the columnar index, for the search index, and for the assistant's reasoning over history.
Query language
We do not expose a graph DSL to customers. Internal services and the assistant use a JSON query interface that compiles to SQL on the row tables, columnar SQL on the analytical tables, or a recursive CTE for traversals. A typical product surface query looks like this:
{
"from": "task",
"where": { "status": ["todo","in_progress"], "due_at": {"lt":"now+7d"} },
"expand": ["assignee","project","references[doc]"],
"sort": [{"field":"due_at","dir":"asc"}],
"limit": 50,
"as_of": "head"
} The same query layer answers an analytical question. The shape changes, the underlying tables don't:
{
"from": "deal",
"where": { "stage": "closed_won", "closed_at": {"between":["2026-01-01","2026-04-30"]} },
"group": ["region","quarter(closed_at)"],
"agg": [{"field":"amount","op":"sum"}],
"having": { "sum(amount)": {"gt": 1000000} },
"as_of": "head"
} The planner picks the columnar path when the predicate selectivity is below 5 percent and the grouping cardinality is below 10,000. A caller does not have to think about this; the thresholds live in the planner, not in each query.
Permissions live in the data
The acl column on every node carries the set of subjects who can read it. Subjects are users, teams, roles, or special markers (everyone-in-tenant, public-link). Every read is rewritten by the planner to add a predicate of the form acl && current_subjects(). This is not a service-layer check, it is a query-layer one. There is no path through the API or the assistant that bypasses it, because there is no other code path.
The trade-off: writes that change permissions can fan out. When you change the owner of a project, every child node inherits the new ACL through a background propagation that runs off the write path, so changing an owner stays fast even when the subtree is large. We chose eventual consistency on permission widening (you might see a doc you couldn't see a second ago) and strong consistency on permission narrowing (you instantly stop seeing things you no longer should).
What the design optimizes for
The point of the split storage is to keep two very different query shapes fast at once. Product reads (open a board, list overdue tasks) touch a small number of rows and should feel instant, in the single or low tens of milliseconds. Analytical reads (sum closed-won by region and quarter) scan far more data and are allowed to be slower, into the hundreds of milliseconds, because a person reading a chart does not feel that lag the way they feel a sluggish board.
To make the tradeoffs concrete, suppose a single tenant grows to tens of millions of nodes and hundreds of millions of edges across a dozen modules. The row path stays fast because every product query is bounded by an index and an ACL predicate; it never scans the whole tenant. The columnar path stays fast because the analytical query reads compressed columns instead of wide rows. The shape I design the columnar tier around is the worst case: a five-way join across nodes, edges, and a derived metric, grouped by two dimensions with a HAVING clause and pagination on top. If that holds up, the lighter shapes come for free.
Where it breaks
It would be dishonest to claim there are no rough edges. The four I keep an eye on, in roughly the order I plan to fix them:
- Long traversals. Anything that needs to walk more than five hops gets expensive. We added a depth cap and a planner warning. The right fix is a materialized reachability index for the most common labels; we have a prototype.
- Hot edges. A small number of tenants have a few super-connected nodes (think: the company-wide "all hands" project). Edge writes against these can serialize behind a single page lock. We split the edge table by hash of
from_idat 64 partitions; this helped, but it's a band-aid. - Schema migrations on jsonb props. The flexibility of jsonb is great until you need to backfill a typed field across 30 million rows. Our migration tooling runs in batches with a kill switch; it still takes hours for the largest tenants.
- Cross-tenant analytical reads. We have no such thing as a customer of customers. But internally we want fleet-wide statistics for capacity planning, and the per-tenant schema makes that awkward. We materialize a sanitized fleet view nightly.
What we would do differently
If I were starting over, I would change two things. First, I would put the columnar index inside the same process as the row store from the start. Running them as separate services for a while cost more operationally than co-locating them would have cost in engineering. Second, I would invest earlier in a query explainer aimed at whoever writes product queries. Early on, making sense of the raw EXPLAIN output took more expertise than it should have, and an opaque query layer is a bottleneck even when it is fast.
What's next
The next major shift is bringing reachability and pathfinding into the planner directly. Right now any "what's connected to what" question goes through a recursive CTE; we want it to be a first-class plan node with its own statistics. We also want to push the columnar tier closer to the assistant runtime, so that a tool call asking "summarize all overdue work for this account" does not need to fan out to twelve module APIs.
If you want to dig into the model itself, the public schema is in our documentation. If you want to argue with any of this, write to hello@wrxstack.com. We read every note, and the best ones become the next post.