Cubes
The semantic layer under HotCRM analytics — what it is, the business questions it answers, and where it stops.
Cubes
A cube is a pre-modeled, multi-dimensional dataset: a set of named measures (the numbers) over named dimensions (the ways you cut them). Cubes are the layer underneath dashboards and reports — they are what makes a number mean the same thing in two places.
In HotCRM that layer is a set of datasets. This app declares no cube of its own: every semantic definition it ships is a defineDataset(...) under src/*/datasets/, registered through objectstack.config.ts, and the analytics service compiles each dataset into its cube internally (ADR-0021). A second, hand-written cube layer did exist here once and was removed — it duplicated every measure and drifted from the datasets that reports and tiles actually bind to. So wherever this page says dataset, that is the object a report or a dashboard tile binds by name, and the thing the platform turns into a cube.
Why a semantic layer (instead of querying raw tables)
- Consistency — "pipeline" means the same thing in every dashboard, because every widget selects the same named measure.
- Speed — pre-aggregated, rather than one ad-hoc scan per question.
- Reuse — a measure is declared once and picked by name.
win_ratecarries its own two halves with it, so no widget has to improvise a denominator.
Where the semantic layer lives
The three dataset barrels — src/sales/datasets/index.ts, src/service/datasets/index.ts and src/revenue/datasets/index.ts — are the semantic layer, and together they are the one place that describes itself completely: every dataset declares its own dimensions and measures, with the labels a report or a tile selects them by, right there in source.
This page does not reproduce that list, on purpose. A roster copied into prose has no producer. It is right on the day it is written and quietly wrong on the day someone adds a measure — a bill this neighbourhood has already paid four times. So when you need the exact name to put in a report or a tile, read src/*/datasets/; that is the source of truth, and nothing here can be fresher than it. What this page is for is the layer's meaning: what it is, which business questions it answers today, and where it stops.
Reach is the fact worth carrying away. The semantic layer covers the records the CRM runs on — customers and the people at them, leads, deals and forecasts, support cases, activities and tasks, and the product catalogue. What it does not reach matters more, because most of the limits below come straight from it: no dataset reads campaigns, contracts, quotes, or the line items under a deal.
This page used to describe four cubes — Sales, Pipeline, Service and Marketing. None of those four names exists in the app. Three of them map onto datasets that do exist, under different names and with different contents; the fourth has no data source at all. Each section below says which.
💰 Sales and 📊 pipeline — one dataset, not two
Both cubes this page described are answered by the same place: Opportunity Metrics, the dataset every pipeline widget and all four opportunity reports bind. That single binding is why pipeline is one number across the app instead of one number per screen.
What it answers. The deal questions a sales manager actually asks: how much is in play and at which stage, who owns it, where it came from, why deals were won or lost, and which industry the customer sits in — this month, or this quarter. It also publishes the win rate as a declared ratio with the won, lost and settled deal counts beside it, so the percentage can be checked instead of trusted. A reader arriving with the words bookings, pipeline value, average deal size or probability bucket will find each of them here under the name this dataset declares; src/sales/datasets/opportunity.dataset.ts is where those names are written down.
Where it stops, and why:
- Cycle time — sales cycle days, days in pipeline, days in stage. Nothing stores a duration. The opportunity carries Stage Entry Date and Close Date but no elapsed-days column, and the one duration it does carry, Days in Current Stage, is a formula evaluated after the query (
src/sales/objects/opportunity.object.ts), so it cannot be aggregated, filtered or sorted, and no dataset exposes it. - Weighted pipeline (value × probability) — the number exists per record: Expected Revenue is the amount times the stage probability, recomputed by
src/sales/objects/opportunity.hook.tson every save, and the opportunity list views total that column. No measure is declared over it, so no tile and no report can. - Anything that lives on a line item — average discount %, product, category, family. The discount is a percent on
crm_opportunity_line_item, and no dataset reads line items. The product catalogue does have a dataset of its own, but it never joins to deals, so "average deal size by product family" has no path even though Family is a real field on the product. - Account tier and account size —
crm_account.tierand Number of Employees are real fields, but the only account cut reachable from a deal is its industry. - Team and region — neither is a field on the deal, and no dataset declares either. Team and territory exist in this app as positions (NA Sales Team, EU Sales Team in
src/sales/sharing/positions.ts) and as Billing Country on the account, a flat projection the territory sharing rules match on — both are access-control machinery, and no dataset reads either. "Bookings by region by quarter" cannot be asked; the nearest answerable cut is by owner. - Period at day, week or year grain — deals bucket by month and by quarter. Another grain means another dimension.
- Pipeline history — nothing snapshots the pipeline, so "pipeline by stage today vs 30 days ago" has no history to compare against. The nearest real thing is Forecast Metrics, which holds one settled row per owner per period — quota, closed, pipeline and commit — and answers the coverage question instead: "coverage ratio (pipeline ÷ quota) by rep", provided the period is pinned.
🎧 Service
One dataset stands behind every case-related dashboard tile and all three case reports, and it reads crm_case alone.
What it answers. How many cases there are and where they come in from, how long they take to resolve, how much of the load knowledge articles are deflecting, and whether SLAs are being met — each of those cut by status, priority, origin, case type, or the day the case was opened. The two rates are declared as ratios with both of their halves published beside them, so no widget improvises a denominator and any reader can check the arithmetic.
⚠️ The SLA figures are two numbers, not one: a violation rate and a compliance rate, complements of each other. The Service dashboard's gauge plots compliance. A report that quotes "the SLA number" without saying which one has said nothing.
There is no first-response measure. case_metrics (src/service/datasets/case.dataset.ts) declares none, so nothing in analytics can aggregate, compare or chart a first response. The First Response Date stamp on the case is real — see Cases for when it is written — but it stops there: nothing measures it, and nothing alerts on it.
Where else it stops:
- Agent, and team — the dataset declares no owner dimension, so nothing in analytics can group, rank or filter cases by agent.
owner_idis a field on the case; no dimension exposes it. This is why the service pages carry no agent leaderboard, and why "reopened cases by agent" is doubly impossible — nothing records a reopen either. - Account, and account tier — it reads
crm_casealone and never crosses tocrm_account. - Product — cases carry no product link that analytics can read, so "cases by category by product" has no path. The nearest real cut to a category is the case Type (Question / Problem / Feature Request / Bug).
- CSAT score — there is no satisfaction data to aggregate. The case object used to declare a Customer Satisfaction rating, but nothing in the product ever collected it — no screen offered it and no automation wrote it — so it was retired rather than left standing as a column a report could point at.
- Open / new / resolved counts as numbers of their own — the pre-filtered counts this dataset declares are on the closed side only. Status is a dimension, so a count split by status gives the same breakdown.
- Period at any grain but day — the created-date dimension buckets by day.
Sample questions this dataset answers:
- "SLA violation rate by priority."
- "Case volume by origin — where is the load coming in?"
- "Average resolution time by type."
📣 Marketing — no dataset, no cube
There is no marketing dataset, so there is nothing to slice. crm_campaign is not one of the objects src/*/datasets/ reads, which makes this the one section on the page where the gap is not a missing measure but a missing data source: every campaign number this page used to offer is unreachable, and none of them can be added from a report or a cube screen.
The records do carry the numbers. crm_campaign has the Number Sent / Number of Responses / Number of Leads / Converted Leads / Opportunities Created / Won Opportunities counters and the Response Rate % formula in the Performance section of the campaign record, and Budgeted Cost, Actual Cost, Expected Revenue, Actual Revenue and the ROI % formula one heading along in Budget & ROI. crm_campaign_member records one thing per member — the response: Status, Response Date and Has Responded, under Response Tracking. Every one of those is a per-record figure that nothing aggregates. An open or a click is a different case: not an unaggregated figure but an unrecorded one — this app tracks no opens, clicks or bounces, and no First Opened or First Clicked stamp exists on a member for a cube to roll up.
Name by name:
- Members enrolled / responded, conversion count — per-campaign counters on the record; no measure, so no total and no ranking across campaigns.
- Sourced and influenced revenue — unreachable from the deal side too:
crm_opportunity.crm_campaignis a real lookup, butopportunity_metricsdeclares no campaign dimension, so revenue cannot be attributed to a campaign anywhere in analytics. - Campaign spend, cost per lead, cost per opportunity, ROI % — the ROI percentage exists on each record as a formula; nothing sums the spend or compares campaigns.
- Campaign (and type), channel — real fields on
crm_campaign, and dimensions nowhere. A period is not one of them: the campaign carries Start Date and End Date, and no dataset reads either. - Lead source — a dimension on both the lead and the deal side, so "which sources produce deals" is answerable; the campaign behind the source is not.
- Persona (job role) — no path at all: Title is free text on the contact and the lead, and the contact dataset declares no dimensions.
So the three questions this section offered — "ROI by campaign type", "cost per opportunity by channel", "conversion rate by persona" — cannot be answered here today. Closing the gap means adding src/marketing/datasets/campaign.dataset.ts: a source change and a redeploy, not a setting. See Campaigns for what the campaign record shows today.
How users interact with datasets
What the app guarantees is the binding: a report or a dashboard tile names a dataset and selects its measures and dimensions by name, and the platform's own checks — objectstack validate and objectstack lint --strict, which pnpm verify and CI both run — fail the build if any of those names does not resolve. That is what keeps two tiles showing the same number — and it is also why the names in src/*/datasets/ are worth reading directly rather than through a copy.
The drag-and-drop pivot experience this section used to describe — pick a cube, drag dimensions to rows and columns, filter, pivot, chart, save the view as a report, pin it to a dashboard — is a platform surface rather than something HotCRM declares. Nothing in this app's metadata configures such a screen, and nothing here tests one, so this page makes no claim about it in either direction. If your workflow depends on self-service pivoting, verify it against your deployment rather than against this page.
How cubes stay fresh
Refresh belongs to the analytics service, not to this app. HotCRM declares no refresh schedule, no incremental-versus-full-refresh policy and no snapshot job anywhere in src/ — the only scheduled work it declares is its own flows (the four packages' flows/ directories). The figures this section used to quote — incremental refresh every few minutes, a nightly full refresh, daily snapshots, a refresh status screen in the admin console — are not configured here and are not measured here; treat them as questions for your deployment.
One half of it is in the app's hands, and it is the half that fails today: a trend chart needs a snapshot dimension to compare against, and no dataset declares one. Time-series questions are answerable only along a date a dataset already carries — a close date, a created date, an activity week, a last-contacted month — each at the grain its own dataset declares.
AI Copilot and cubes
On this app's side, nothing connects the Copilot to a dataset. The six skills under src/*/skills/ name no dataset, no cube and no measure. The one that answers data questions, Live Data Access, is wired to the platform's object tools — describe_object, list_objects, query_records, get_record, aggregate_data — and its instructions tell the agent to re-read the object's schema first and aggregate over records. So a Copilot answer about win rate is computed from the objects, not read off the compiled cube, and this app declares nothing that would make the two consistent by construction.
Whether the platform's own agent additionally reads the compiled cubes, and whether cube access is audited for admins, are questions about the runtime rather than about this app — so this page makes no claim about them in either direction. If you are relying on Copilot answers matching a dashboard to the decimal, check it against your deployment.
Custom cubes
In HotCRM a new cube starts as a new dataset in source: a defineDataset(...) in the owning package's datasets/<object>.dataset.ts, exported from that directory's index.ts, registered by the stack of the package that owns it — merged into appDatasets by objectstack.composition.ts for the app package (sales, revenue and marketing), named in src/service/index.ts for the service module — then deployed. That is the route every dataset in the app took, and the reason the marketing gap is a code change rather than a configuration one.
The three examples this section used to offer — a Subscriptions Cube, a Renewal Cube, a Partner Cube — exist in no form; there is no subscription or partner object to build them on. Whether the console additionally offers an admin-facing cube builder is, again, a platform question this page does not answer.
Tips for analysts
- ✅ Start from a dataset and its declared measures — the number a tile shows is the measure it names, so quoting the measure name makes an answer checkable.
- ✅ Pin the period when you touch Forecast Metrics: its measures are plain sums over a table that holds several periods at once, so an unscoped query adds a quarter's quota to a month's.
- ✅ Read Win Rate together with Won Deals and Lost Deals — a blank rate means "no settled deals", not "no wins".
Tips for admins
- ✅ When a measure is computed inconsistently across reports, promote it into the dataset — one declaration, selected by name everywhere.
- ✅ Adding a dimension is how you close an "unreachable" gap above; adding a dataset is how you close a missing-source one. Both are source changes, so they go through review like any other metadata.
- ✅ Document the business question, not the inventory. What each dataset can and cannot answer is worth writing down — the gaps on this page are exactly the questions users kept asking. The dimensions and measures themselves are not: they already describe themselves in
src/*/datasets/, and a second copy in prose only tells you what was true the day it was typed.