Reports, dashboards & analytics
Reporting is where a platform’s access model is most often quietly suspended. A number on a dashboard is an aggregate, and an aggregate is the easiest place in a product to leak a value the reader was never granted: one SUM over rows they cannot open, rendered as a tile with no row to click, and the leak has no visible surface at all.
Two decisions carry this page.
A report runs as the person reading it. Always. There is no running user, no “view dashboard as,” no static-versus-dynamic distinction, and no edition in which the safe mode costs more. Every widget on every dashboard re-executes its report under the viewer’s own record access and field permissions, through the same query path as any other read. When a number genuinely must be shown to someone who cannot see its inputs — a plant total to a shop lead, a company bookings figure to the whole company — that number is published: computed once under a named identity and stored as an ordinary record with an owner and a grant. Elevated aggregates become data that someone is accountable for, not a live query wearing somebody else’s badge.
A report has no format. List, grouped, and matrix are not three kinds of report; they are one query with zero, n, or n × m groupings, and the incumbent’s format enum exists only because its builder cannot change shape without a conversion. A joined report is genuinely different — a union of independent queries aligned on a shared grouping — so it survives, as a second block on the same component rather than as a crippled fourth format. The shape a report renders in is derived from its grouping spec, and every feature is available in every shape.
Four shapes are therefore reachable, and none of them is declared:
| Shape | What the grouping spec holds | What renders |
|---|---|---|
| List | No groupings | Detail rows and a grand total |
| Grouped | One or more down-axis groupings | Group headers, per-level subtotals, optional detail rows, a grand total |
| Matrix | Groupings on both axes | Cells at the intersections, subtotals on both edges, a grand total in the corner |
| Joined | More than one entry in blocks[] |
One column band per block, aligned on the shared down-axis grouping, with cross-block summaries |
Adding an across-axis grouping turns a grouped report into a matrix. Adding a second block turns any of them into a joined report. Nothing converts, and no feature is lost on the way.
The model
Section titled “The model”| Layer | What it is | Where it lives |
|---|---|---|
| Report | A query: a source object, named traversals, columns, one filter expression, groupings, summaries, sort, and a freshness window | A canonical component, type: "report" |
| Block | One report’s worth of query, aligned to its siblings on a shared grouping | An entry in the report’s blocks[]; a single-block report omits it |
| Chart | A bound view of a report’s groupings and summaries, from a closed catalog | Inside the report, or inside a dashboard widget |
| Dashboard | An ordered grid of widgets, each bound to a report and a view, plus filters that bind into every widget’s query | A canonical component, type: "dashboard" |
| Subscription | A schedule that runs a report and delivers the result | A canonical component, type: "report_subscription" |
| Snapshot | A report result computed under a named identity and written into an object as records | A canonical component, type: "report_snapshot" |
| Folder & sharing | Who may see, run, and edit a report or dashboard | Ordinary record access on the report and dashboard objects |
Everything below the first row is a view of or a schedule over the first row. There is one query artifact.
There is no report type
Section titled “There is no report type”Salesforce requires a second artifact before a report can exist: a report type, which fixes the root object and the set of related objects a report may traverse. It is a real constraint — a custom report type joins a maximum of four objects, and once an outer join appears in the sequence no later join may be an outer join (Metadata API — ReportType).
A CAOS report declares its own traversals, directly from the object model, in the report itself:
- Each entry in
pathsnames a relationship the report may cross and whether it is an inner or an outer join. - A to-one path (
account,owner) projects columns. A to-many path (lines) fans out detail rows and is the input to a report-level roll-up. - Traversal depth and join count are bounded by the report’s cost budget, not by a fixed number, and nothing forbids an outer join after an outer join, because the compiler emits
LEFT JOINand the planner handles the rest.
Removing the report type removes an entire admin artifact — one to name, categorize, deploy, keep in sync with the schema, and count against a per-edition allocation. The schema graph is already metadata; a report reads it.
What a report is not
Section titled “What a report is not”A list view answers “which records,” returns records, and links to them. A report answers “what do these records add up to,” returns groupings and aggregates, and links to the detail underneath. The two share the filter grammar and the table that renders their rows, and nothing else; a list view is not a degenerate report and does not compile through this path.
A roll-up summary field is a maintained column on a parent record. A report roll-up is computed at run time and lives nowhere. The choice between them is covered under report roll-ups versus stored roll-ups, and it comes down to one question: does anything other than a human eye need the number.
Folders, and who may open a report
Section titled “Folders, and who may open a report”A report has two halves that are governed separately, and conflating them is what makes reporting security hard to reason about in most products.
The definition is a metadata component. It diffs, deploys, versions, and promotes between environments like a field or a page layout. The grant — who may see the report in a list, run it, edit it, or move it — is ordinary record access on the report and dashboard objects, exactly as it is on an Account.
Folders are the container that carries those grants. A folder component declares a label, a parent, and a type, folders nest, and a report inherits the grants of the folder it sits in. A grant may be placed on a single report as well, and the two are additive, because permissions on this platform are always additive. Three access levels exist on a folder: View (see the reports in it and run them), Edit (change their definitions), and Manage (change the folder’s own grants and move reports in and out). There is no separate folder-sharing model, no folder-only role, and no “hide from users without access” toggle — a report a reader has no grant on is absent, consistent with not-found beating forbidden.
Two consequences are worth stating. A grant on a folder is a grant to run what is in it, not a grant to the data underneath — every report in a shared folder still executes under the reader’s own record and field access, so sharing a folder widely is safe in a way that sharing a dashboard’s running user never was. And a report moved between folders changes its grants at the moment it moves, which the move surface states before it happens rather than after.
Authoring
Section titled “Authoring”A report is one component. The example below is a matrix — groupings on both axes — with a computed column, a blended summary, detail rows, and a chart. Nothing in it selects a format.
{ "key": "report_open_invoices_by_account_manager", "label": "Open invoices by account manager", "type": "report", "body": { "source": "invoice", "paths": { "account": { "via": "account_id", "join": "inner" }, "account_manager": { "via": "owner_id", "join": "outer" } }, "filter": "record.status != \"Draft\" && record.created_at >= today().minusMonths(6)", "columns": [ { "key": "name", "value": "record.name" }, { "key": "account", "value": "record.account.name" }, { "key": "sell_price", "value": "record.sell_price", "format": "currency" }, { "key": "margin_pct", "label": "Margin %", "format": "percent", "value": "record.sell_price > 0 ? (record.sell_price - record.total_cost) / record.sell_price : 0" } ], "groupBy": [ { "axis": "down", "value": "record.account_manager.full_name", "sort": "label" }, { "axis": "across", "value": "record.created_at", "granularity": "month" } ], "summaries": [ { "key": "total_sell", "value": "sum(record.sell_price)", "format": "currency" }, { "key": "blended_margin", "format": "percent", "value": "(sum(record.sell_price) - sum(record.total_cost)) / sum(record.sell_price)" } ], "detail": { "show": true, "pageSize": 200, "sort": [{ "value": "record.sell_price", "direction": "desc" }] }, "chart": { "type": "column_stacked", "measure": "total_sell" }, "freshness": "PT15M" }}One filter, one language
Section titled “One filter, one language”filter is a single boolean expression in the one typed language, pure tier, the same grammar as a formula field, a validation rule, or a sharing rule’s covers. There is no criteria list, no separate filter-logic string of the form 1 AND (2 OR 3), and no filter dialect. &&, ||, !, and parentheses already express boolean logic; a second notation for the same thing would be a second thing to learn, type-check, and get wrong.
Three values are bound inside a report filter:
| Bound value | What it is |
|---|---|
record |
The row from the source object, with declared paths reachable as typed properties |
viewer |
The user running the report — viewer.id, viewer.role, viewer.plant, any field on the user object they can read |
today() |
The run’s date, resolved once per run in the run’s declared time zone |
viewer is what replaces the incumbent’s hard-coded $User special case and, more importantly, what makes “my open invoices” a single report rather than one per person. today() resolving once per run is what makes a run reproducible: a report that straddles midnight does not produce two different date boundaries in the same result.
A cross filter — “accounts with no open invoices” — is not a separate feature with its own cap. It is a quantifier in the same expression:
record.stage == "Active" && !record.invoices.some(i => i.status == "Sent")Buckets are expressions
Section titled “Buckets are expressions”A bucket is a column whose value is a pure expression returning a label. There is no bucket object, no per-report bucket allocation, and no cap on the number of buckets or on the values inside one:
{ "key": "size_band", "label": "Size band", "bucket": true, "value": "record.sell_price >= 250000 ? \"Large\" : record.sell_price >= 50000 ? \"Mid\" : \"Small\"" }The bucket: true flag is presentational — it tells the builder to offer the drag-values-into-named-bins editor instead of the expression editor, and to sort the result in the order the bins were declared rather than alphabetically. The editor writes the expression; the expression is the artifact. Anything the bin editor cannot express is written directly, and a bucket may reference another computed column, because it is an ordinary node in the report’s expression graph.
Row-level and summary formulas
Section titled “Row-level and summary formulas”Both are columns. They differ only in what they are allowed to reference:
- A row-level formula evaluates once per detail row, over
record.margin_pctin the example above is one. - A summary formula evaluates once per grouping level, over aggregate functions —
sum,count,countDistinct,avg,min,max,median,percentile.blended_marginabove is one, and it is deliberately not the average of the row-level margins; a blended margin is a ratio of sums, and the language makes the difference visible instead of hiding it behind a picklist of summary types.
Both may reference other computed columns. A report’s columns form a directed acyclic graph, cycle-checked at author time, so a bucket can feed a row-level formula which can feed a summary formula. A summary formula declares the level it is evaluated at — a specific grouping, or all for the grand total — and defaults to every level.
Blocks
Section titled “Blocks”A second block turns the report into a joined report. Blocks share the report’s source object and its down-axis grouping, and nothing else: each block carries its own filter, paths, columns, and summaries.
"blocks": [ { "key": "unpaid", "label": "Unpaid", "filter": "record.status == \"Sent\"", "summaries": [{ "key": "amt", "value": "sum(record.sell_price)" }] }, { "key": "paid", "label": "Paid", "filter": "record.status == \"Paid\"", "summaries": [{ "key": "amt", "value": "sum(record.sell_price)" }] }, { "key": "collected", "label": "Collected", "crossBlock": true, "format": "percent", "value": "blocks.paid.amt / (blocks.unpaid.amt + blocks.paid.amt)" }]A cross-block formula references blocks.<key>.<summary> and is an ordinary summary formula whose inputs happen to come from more than one block. Blocks lose nothing: buckets, row-level formulas, charts, dashboard binding, and filters all work inside a block exactly as they work in a single-block report.
Dashboards
Section titled “Dashboards”{ "key": "dash_billing_pipeline", "label": "Billing pipeline", "type": "dashboard", "body": { "widgets": [ { "key": "w_total", "report": "report_open_invoices_by_account_manager", "view": { "type": "metric", "measure": "total_sell", "compareTo": "previousPeriod" } }, { "key": "w_by_account_manager", "report": "report_open_invoices_by_account_manager", "view": { "type": "bar", "grouping": "down", "measure": "total_sell", "maxGroups": 20 } }, { "key": "w_detail", "report": "report_open_invoices_by_account_manager", "view": { "type": "table", "columns": ["name", "account", "sell_price"] } } ], "layout": [ { "widget": "w_total", "col": 1, "row": 1, "colSpan": 3, "rowSpan": 2 }, { "widget": "w_by_account_manager", "col": 4, "row": 1, "colSpan": 9, "rowSpan": 2 }, { "widget": "w_detail", "col": 1, "row": 3, "colSpan": 12, "rowSpan": 4 } ], "filters": [ { "key": "plant", "label": "Plant", "binds": "record.plant", "values": "picklist:plant" } ], "freshness": "PT15M" }}There is no runningUser key and no dashboardType. Their absence is the design, not an omission. The dashboard and its widgets are rendered by a package; the report compilation, the query, and the access planes each widget re-executes against stay in the kernel — only the rendering is a package surface.
A dashboard filter is a bound parameter, not a post-filter: binds names a path that is spliced into each widget’s compiled WHERE clause as a parameter. A widget whose report cannot reach that path is skipped and labelled as such, rather than silently showing unfiltered numbers beside filtered ones. Filter values are part of the dashboard’s addressable state, so a filtered dashboard has a URL — and, the part the incumbent gets wrong, a subscription created from a filtered dashboard delivers that filtered dashboard.
Subscriptions and snapshots
Section titled “Subscriptions and snapshots”Both are schedules, and both run on the background-work engine rather than on a scheduler of their own. They inherit its occurrence identity, its daylight-saving policy, its onMissed and overlap semantics, its retry and dead-letter path, and its observability surface.
{ "key": "sub_open_invoices_weekly", "label": "Open invoices — Monday 07:00", "type": "report_subscription", "body": { "report": "report_open_invoices_by_account_manager", "schedule": { "cron": "0 7 * * MON", "timeZone": "America/Chicago" }, "runAs": "recipient", "recipients": ["group_billing_team"], "filterValues": { "plant": "Springer" }, "deliverWhen": "summary.total_sell > 0", "attach": [{ "format": "xlsx", "rows": "detail" }] }}{ "key": "snapshot_bookings_by_plant", "label": "Bookings by plant — published", "type": "report_snapshot", "body": { "report": "report_bookings_by_plant", "schedule": { "cron": "0 6 * * *", "timeZone": "America/Chicago" }, "runAs": "service:analytics_publisher", "into": "published_metric", "map": { "plant": "grouping.0", "period": "grouping.1", "amount": "summary.total_sell" }, "retain": "P2Y" }}A snapshot writes rows into an ordinary object. Those rows have an owner, an org-wide default, sharing rules, field permissions, and a history — every mechanism that governs any other record. Publishing a number to people who cannot see its inputs is therefore an explicit, reviewable, auditable act with an artifact attached, which is exactly what a running user is not.
Semantics & evaluation
Section titled “Semantics & evaluation”Compilation
Section titled “Compilation”A report compiles to one statement, planned before it runs.
SELECT grouping(account_manager_name, created_month) AS lvl, account_manager_name, created_month, sum(sell_price) AS total_sell, (sum(sell_price) - sum(total_cost)) / sum(sell_price) AS blended_marginFROM ( SELECT i.id, u.full_name AS account_manager_name, date_trunc('month', i.created_at) AS created_month, i.sell_price, i.total_cost FROM invoice_r i -- field-permission projection view LEFT JOIN user_r u ON u.id = i.owner_id WHERE i.status <> 'Draft' AND i.created_at >= $run_date - interval '6 months') sGROUP BY GROUPING SETS ( (account_manager_name, created_month), -- cells (account_manager_name), -- row subtotals (created_month), -- column subtotals () -- grand total)Three things about that statement matter more than the rest.
GROUPING SETS produces every subtotal level in one pass. A matrix report is not four queries and it is not a client-side rollup; cells, row subtotals, column subtotals, and the grand total come back from a single scan, tagged by grouping() so the renderer knows which level each row belongs to. This is why the format enum can be deleted: adding an across-axis grouping adds a grouping set, and nothing about the query’s structure changes.
The record-access predicate is not in the WHERE clause. It is the row-security policy on invoice_r and user_r, applied by Postgres on every access path, exactly as described in roles & record sharing. A report cannot forget to apply it, cannot apply a stale copy of it, and cannot be written in a way that skips it, because the report compiler never had the ability to emit it in the first place.
Detail rows are a second, bounded statement. Aggregates come from the scan above; detail rows come from a keyset-paginated query over the same subquery with the same filter. Aggregates are never derived from a truncated detail set, so a grand total is a grand total of the whole visible set even when only the first page of rows is on screen.
The three access planes in a report
Section titled “The three access planes in a report”| Plane | Where it lands | Failure behavior |
|---|---|---|
| Object permission | The source object and every object crossed by a path |
The report does not run. It names the object, by label, to the reader — an object’s existence is not the secret; its rows are |
| Record access | The row-security predicate on every relation in the FROM clause |
Rows are absent. Nothing is reported, because reporting it would be the leak |
| Field permission | The projection view, which nulls unreadable columns | The column and everything derived from it are removed from the run, and the removal is stated |
The third one needs its rule spelled out, because “the column reads as null” is the wrong answer inside an aggregate — a nulled cost column would make sum(record.total_cost) return zero and a margin read 100%, which is worse than an error and much worse than a blank.
A column the reader cannot read is removed from the run, not nulled. The report’s expression graph is walked: the unreadable column goes, then every bucket, row-level formula, summary formula, grouping, sort, and chart series that depends on it, transitively. The result renders with a header line naming what was removed and why — “Cost and Margin % are hidden by field permissions.” A report never shows a number computed from a value the reader could not read.
Filters are the exception, and the distinction is deliberate. A filter authored in the report component still applies even when the reader cannot read the column it names. A filter narrows a set; it does not project a value, and dropping it would show the reader more rows than the author intended, which is the opposite of the protection field permissions provide. Reader-supplied ad-hoc filters — the ones typed in while viewing — are restricted to columns the reader can read, because an ad-hoc filter over a hidden column is an oracle: binary-search the boundary and the value falls out in a dozen refreshes. Authored filters are deploy-controlled and reviewed; ad-hoc filters are not, so the two get different rules.
Aggregates over rows the reader cannot see
Section titled “Aggregates over rows the reader cannot see”An aggregate is computed over exactly the rows the reader may read, and no others. There is no elevated-aggregate mode, no summary-only access level, no report-level viewAll, and no configuration that widens the input set of a SUM without widening access to the rows underneath it.
Four consequences follow, and all four are intended:
- Two people running the same report legitimately see different numbers. This is correct. The alternative — everyone seeing the same number — is only achievable by showing someone rows they were not granted.
- A total never reveals what it excluded. No “1,284 of 4,000 rows,” no dimmed count, no
nullgrouping labelled Restricted. A hidden row is absent, consistent with not-found beating forbidden. - A dashboard is not a shortcut around this. Every widget re-runs its report as the viewer. A dashboard tile is exactly as privileged as opening the report, which is exactly as privileged as opening the records.
- Showing a wider number requires publishing it. A snapshot computes the number once under a named identity and writes it into an object with its own access. The regional lead who may see the company total but not the individual invoices is granted Read on
published_metric, not oninvoice.
The fourth point is the entire replacement for the running user. It is more work to set up, and it is the only version of this capability that leaves an audit trail, an owner, a timestamp, and a grant to revoke.
Report roll-ups versus stored roll-ups
Section titled “Report roll-ups versus stored roll-ups”Both compute a parent-level number from child rows. They are not interchangeable:
| Report roll-up | Stored roll-up | |
|---|---|---|
| Where the value exists | Only inside the run | A real column on the parent |
| Cost | Paid per run, by the reader | Paid per child write, by the writer |
| Filterable by other reports and list views | No | Yes |
| Usable in a validation rule, a formula field, an automation, or a sharing predicate | No | Yes |
| Reflects the reader’s record access on children | Yes — it aggregates only visible children | No — it is a stored value, governed by field permissions on the parent |
That last row decides most cases. A stored roll-up is a disclosure: the sum of all children, visible to anyone with field Read on the parent column, regardless of their access to the children. That is often correct — an order total should not shrink because a reader cannot see one line — and it is a decision the author makes when creating the field. A report roll-up makes no such disclosure and cannot be used anywhere except a report.
The rule: if anything other than a human eye needs the number, store it. Filters, validation, automation, and list views all need real columns. If the number is only ever read, report it, and pay for it at read time.
Dashboards, refresh, and the access fingerprint
Section titled “Dashboards, refresh, and the access fingerprint”Running every widget as every viewer sounds like it removes all opportunity to cache. It does not, because most users’ access is not unique.
Each request carries an access fingerprint: a hash over the viewer’s effective role, permission-set-group assignments, and group-closure membership — the same closures the record-access predicate already maintains. Two users with an identical fingerprint provably resolve the same predicate, so they may share a cached result. The cache key is:
(report key, metadata generation, filter values, access fingerprint)A sales team of forty people in one role with one permission set is one cache entry, not forty query runs. An organization where every user’s access is genuinely unique gets no cache benefit and honestly should not, because in that organization every user really is asking a different question.
Cache entries live for the report’s freshness — an ISO-8601 duration, default PT15M — are invalidated by a metadata generation flip, and are bypassed by an explicit refresh. Every rendered widget states the age of its data. A dashboard that quietly shows a two-hour-old number with no indication is a defect, not a performance optimization.
Refresh is not a background process an admin schedules. A dashboard refreshes when it is opened past its freshness window, or when someone asks it to. Scheduled refresh exists only as cache warm-up: an optional warm block that pre-runs the widgets for the most common access fingerprints ahead of a known peak. It changes latency, never content, and its absence changes nothing but the first viewer’s wait.
Subscriptions and scheduled delivery
Section titled “Subscriptions and scheduled delivery”runAs on a subscription is the same choice the background-work engine offers everywhere, with one form specific to reporting:
runAs |
Who the query runs as | When to use it |
|---|---|---|
recipient (default) |
Each recipient, individually | Everyday delivery. Nobody receives numbers they could not have run themselves |
subscriber |
The person who created the subscription | A deliberate act of sharing one’s own view, recorded as such and named in the delivery header |
service:<key> |
A named service user | Machine-facing delivery to an external system, where no human’s access is the right one |
recipient is the default and it is not expensive, because delivery is grouped by access fingerprint: a subscription for a two-hundred-person group runs once per distinct fingerprint — typically a handful of runs — and each recipient receives the one matching their access. A delivery header always names whose access produced the numbers, in every mode.
deliverWhen is a pure boolean over the summary row: summary.total_sell > 0, summary.overdue_count >= 5. It is evaluated after the run and before delivery. A subscription that would deliver an empty or unchanged report is recorded as suppressed rather than skipped silently, so “did the Monday report run” has an answer even when no mail arrived.
Delivery routes through notifications, not through a private mail path, so channel preferences, quiet hours, digest batching, and delivery receipts apply to a scheduled report exactly as they apply to anything else the platform sends.
Export
Section titled “Export”Export is a job, always. Format and row count decide only how fast the job finishes.
- Under the interactive threshold — 2,000 rows, the same figure the incumbent uses for a displayed report — the export streams to the browser directly.
- Above it, the job runs, writes a file into the tenant’s storage, and notifies the requester with a signed link. There is no synchronous ten-minute wall to hit, because past the threshold nothing is waiting on it. Where the incumbent leaves the point at which an export goes background undocumented, CAOS pins it at 2,000 rows.
- The export runs under the requester’s access, resolved at run time — an export queued at 09:00 and produced at 09:04 reflects the requester’s access at 09:04, not a copy of it taken at queue time.
- Formats:
csvandxlsxfor detail rows,xlsxpreserving grouping structure and number formats, andjsonfor machine consumers. - Every export is recorded in the audit stream with the report key, filter values, row count, and requester. A bulk export of customer data is exactly the event an auditor asks about, and it is not inferable from a mail log.
Charts
Section titled “Charts”The catalog is closed at twelve types, and each entry declares the shape of data it accepts. That is the point of closing it: a chart type is a claim about the data, and a claim the compiler can check is a claim the reader can trust.
| Chart | Accepts | What it is for |
|---|---|---|
bar / column |
1 grouping, 1 measure | Comparing a measure across categories. Bars for long labels and many values, columns for short ones and dates |
bar_stacked / column_stacked |
1 grouping, 1 series grouping, 1 additive measure | Composition within each category. The total is readable; the parts are not comparable across categories |
bar_grouped / column_grouped |
1 grouping, 1 series grouping, 1 measure | Comparing the series within each category. The parts are comparable; the total is not readable |
line |
1 ordered grouping, 1–4 measures | A measure over an ordered dimension, nearly always a date |
area_stacked |
1 ordered date grouping, 1 series grouping, 1 additive measure | Composition over time, where the total matters as much as the parts |
donut |
1 grouping (≤ 8 values), 1 additive measure | Part-to-whole at a single instant, with the total stated in the middle |
funnel |
1 ordered grouping, 1 measure | Progression through declared stages, where each stage is a subset of the one before |
scatter |
1 grouping, 2 measures, optionally a third as point size | Correlation between two measures, one point per group |
heat_matrix |
2 groupings, 1 measure | A matrix report’s cells as color intensity — the dense alternative to a hundred-bar chart |
metric |
0 groupings, 1 measure | One number, optionally against a comparison period or a target |
gauge |
0 groupings, 1 measure, declared ranges | One number against thresholds that carry meaning — a target, a capacity, a limit |
table |
Any | Grouped rows with their summaries. The honest choice when no chart adds anything |
Three families are deliberately absent, and their absence is part of the catalog:
- Pie.
donutmakes the identical part-to-whole comparison and states the total in the middle, so shipping both would ship a strictly worse option beside a better one. - Dual-axis combination charts. A second value axis makes two unrelated scales look comparable, and the reader has no way to tell which of the two apparent relationships the author intended. Two measures on one scale go on a
line; two measures on different scales are two charts. - Radar, three-dimensional variants, and decorative types. Each one adds a way to misread a number and buys nothing.
Author-time checks are refusals, not warnings:
- A
donutbound to a grouping with more than eight values is rejected, with the value count and two offered fixes: bucket the tail into Other, or switch tobar. - A
donutbound to a non-additive measure — an average, a percentage, a ratio — or to a measure that can go negative is rejected. Slices of a whole must sum to the whole. - A
funnelwhose grouping has no declared order is rejected. A funnel implies monotonic decline, and an unordered picklist cannot promise it. - A
lineover an unordered categorical grouping is rejected. Connecting unordered categories draws a trend that does not exist. - A
gaugewithout declared ranges is rejected. Without thresholds it is a metric with extra ink. - A
metricbound to a grouping is rejected. One number computed over rows the reader is not shown is a chart hiding its own detail.
Series color is assigned by the platform’s categorical palette in declaration order, with a diverging palette for measures that cross zero and a sequential palette for heat_matrix. Color is not a per-chart author choice, because a dashboard whose six widgets each picked their own colors for the same five plants is unreadable in a way no individual widget is responsible for. Nothing on a chart is carried by color alone: every series is identifiable from its legend label, from hover identification, and from the values on the plot.
Thresholds and conditional formatting
Section titled “Thresholds and conditional formatting”Both are the same mechanism: an ordered list of bands, each a pure boolean over the value being rendered, resolving to one of the platform’s semantic tints. A gauge requires them, a metric may carry them, and any column on a report may carry them per cell.
"bands": [ { "when": "value < 0.15", "tint": "critical" }, { "when": "value < 0.25", "tint": "warning" }, { "when": "true", "tint": "positive" }]Bands are evaluated in order, first match wins, and the tint names a meaning rather than a color, so the same band reads correctly in either theme and survives a palette change. A band expression that does not type-check against the measure it is bound to is rejected at author time, like any other expression on the platform.
Performance and materialization
Section titled “Performance and materialization”What makes a report slow
Section titled “What makes a report slow”| Cause | Why | What the platform does |
|---|---|---|
| Grouping or filtering on an unindexed column | Sequential scan, then sort | The deploy diff names the column and offers the index as an expand-phase migration |
| High-cardinality grouping | Ten thousand groups computed, sorted, transferred, and rendered to be scrolled past | Refused above the report’s group budget, with the estimated cardinality and a bucketing suggestion |
| Outer join to a large child, then aggregate | Fan-out multiplies rows before GROUP BY collapses them |
The planner’s estimated row count is checked against the budget before the run |
| A formula the compiler cannot push into SQL | Evaluation moves out of the database, per row | Rejected at author time, naming the construct that blocked pushdown |
| Sorting an unbounded detail set | The whole set is sorted to show two hundred rows | Detail is always keyset-paginated; there is no unbounded detail sort |
A contains predicate over long text |
No index is selective for a leading wildcard | Routed to the search path, or refused |
Author-time refusal
Section titled “Author-time refusal”A report’s cost is estimated before it is saved, and again before it is run. The builder issues EXPLAIN against live statistics; if the estimated cost exceeds the source object’s report budget, the report does not save, and the message carries the plan node dominating the cost, the estimated row count, and the specific change that would fix it.
This is the same discipline the search path applies to non-selective queries, and it exists for the same reason: a query that is going to be too expensive is cheaper to refuse than to run, and far cheaper to refuse at author time than at 08:55 on the morning of a board meeting. Every run — including every refusal — lands in the execution trace with its plan, duration, row counts, and cache disposition, so “the pipeline dashboard is slow” is answerable from telemetry rather than from reproduction.
statement_timeout is set on every report query as the backstop for a plan that was wrong, and a per-tenant concurrency limiter caps simultaneous report execution. Schema-per-tenant isolates data, not CPU.
Three tiers of materialization
Section titled “Three tiers of materialization”| Tier | What it is | Freshness | Use |
|---|---|---|---|
| Live (default) | Compiled and executed per request | Exact | Everything, until measurement says otherwise |
| Cached | A live result held under (report, generation, filters, access fingerprint) |
The report’s freshness |
Dashboards, repeated views, anything read far more often than the data changes |
| Snapshot | A run written into an object as records, under a named runAs |
The schedule | Trend history, published metrics, any number shown to someone who cannot see its inputs |
The tiers are ordered by how much they give up. Live gives up nothing and costs the most. Cached gives up recency, bounded and displayed. A snapshot gives up recency and moves the access decision onto the author — which is precisely why it is the right home for elevated numbers and the wrong default for everything else.
Snapshot rows are ordinary records. They carry a history, they are backed up and restored with the tenant, they can be reported on (a report over published_metric is just a report), and they obey the retention declared in retain.
Limits, and the reasons behind them
Section titled “Limits, and the reasons behind them”| Concern | CAOS | Salesforce | Why their limit exists |
|---|---|---|---|
| Rows a report displays | Keyset-paginated, no total cap; aggregates always cover the whole visible set | “Reports display a maximum of 2,000 rows. To view more rows, export the report to Excel or use the printable view.” | A materialized result set with an offset-based pager |
| Groupings | 4 down × 3 across | 3 down for summary (2 for matrix), 2 across | Grouping sets multiply — (down+1) × (across+1) levels per run — and past this a matrix is unreadable before it is slow |
| Columns per report | 200 | Joined blocks capped at 100 columns each; a report drawing columns from more than 20 objects errors | Projection width; past 200 the artifact is an export, not a report |
| Report filters | One expression; complexity bounded by the cost budget | 20 field filters; up to 3 cross filters with 5 subfilters each | Each filter is a discrete UI row against a fixed query template |
| Row-level formulas | No cap; any field type; may reference other computed columns | “Each report supports 2 row-level formulas”; each referencing “up to 5 unique fields”; may not reference buckets, summary formulas, or other row-level formulas | A bolt-on evaluated outside the report’s own query |
| Summary formulas | No cap; dependency depth 10 | 5 per report; 10 per joined block; 10 cross-block | Fixed per-report evaluation slots |
| Buckets | No cap — a bucket is an expression | 5 bucket fields per report, 20 buckets per field, 20 values per bucket | A bucket is a stored definition rather than an expression |
| Blocks in a joined report | No fixed cap; each block’s cost counts against the report budget | 5 blocks | Each block is a separate query against a shared frame |
| Objects a report may traverse | Bounded by cost budget; outer joins compose freely | “A maximum of four objects can be joined in a custom report type”; no outer join after an earlier outer join | The report type is a precompiled join graph |
| Dashboard widgets | No fixed cap; summed widget cost must fit the dashboard budget; off-screen widgets load lazily | 25 widgets, of which 20 charts and tables, 3 images, 25 rich text | A fixed render budget per dashboard |
| Dashboard filter values | No cap — a filter is a bound parameter drawn from the field’s own domain | “A dashboard filter can have up to 50 values” | Values are enumerated into the filter definition |
| Filters in scheduled delivery | Carried; a subscription delivers the dashboard as filtered | “Filters aren’t applied in dashboard subscription emails” | Scheduling runs the underlying report, not the filtered dashboard |
| Subscriptions per user | No fixed cap; scheduled runs are governed by the job queue | 15 report and 15 dashboard subscriptions per user on Unlimited and Performance; 7 and 7 elsewhere; a hard cap | A scheduler with fixed per-hour slots |
| Viewer-scoped dashboards | Every dashboard, always. Not a feature | 3 per org on Developer, 5 on Enterprise, 10 on Unlimited and Performance | Viewer-scoped dashboards cannot share a cached result, so cost scales with viewers |
| Export | A job; unbounded rows; delivered as a file | “If a report takes 10 minutes to export, the export times out and fails”; joined-report export capped at 20,000 rows | Export runs synchronously inside a request |
| Report run time | statement_timeout per run, plus author-time cost refusal |
“By default, reports time out after 10 minutes” | A timeout without an author-time gate is the only available defense |
Two caps deserve their reasoning stated rather than tabulated.
Groupings are capped at 4 × 3 because grouping sets multiply. Four down-groupings and three across-groupings compute twenty grouping levels in a single pass. The cap is not about readability alone — it is the point past which one query’s cost grows faster than the value of the extra dimension. A report that needs more dimensions is a report that should have bucketed one of them.
Columns are capped at 200 because past that the artifact is not a report. Two hundred columns cannot be read, charted, or grouped meaningfully; what the author wants is an export, and the export path has no column cap.
How Salesforce does it
Section titled “How Salesforce does it”A Salesforce report is built on a report type — a separate metadata artifact fixing the root object and the related objects available to it. A custom report type joins a maximum of four objects, and “when more than two objects are joined, an outer join isn’t allowed if there has been an outer join earlier in the join sequence” (Metadata API — ReportType). A custom report type may contain up to 60 object references and 1,000 fields, and a report drawing columns from more than 20 different objects errors (Reports and Dashboards Limits and Allocations).
Reports then take one of four formats (Report Formats in Salesforce Classic):
- Tabular — “the simplest and fastest way to look at data,” but they “can’t be used to create groups of data or charts, and can’t be used in dashboards unless rows are limited.”
- Summary — tabular plus grouping, subtotals, and charts.
- Matrix — grouping and summarizing “by both rows and columns.”
- Joined — “multiple report blocks that provide different views of your data,” each with its own fields, columns, sorting, and filtering, and “available only in Enterprise, Performance, Unlimited, and Developer Editions.”
The joined format pays for its independence with an unusually long list of exclusions: 5 blocks of 100 columns, export and printable view capped at 20,000 rows, no dashboard filtering for “widgets from joined report charts,” and row-level formulas that “aren’t available on joined reports” (Limits and Allocations; Filter a Dashboard; Row-Level Formulas: Tips, Limits, and Limitations).
Row-level formulas are the clearest case of a bolt-on. Each report supports 2 of them; each may reference “up to 5 unique fields”; they cannot reference bucket fields, summary formulas, or other row-level formulas; they are unsupported on Boolean, Timeonly, Email, Phone, and multi-select picklist fields; “row-level formulas always use an org’s default currency” and “don’t respect multi-currency settings”; and they are unavailable in Salesforce Classic, in reporting snapshots, on joined reports, and in the Apex API (Row-Level Formulas: Tips, Limits, and Limitations).
Display caps at 2,000 rows. “Reports display a maximum of 2,000 rows. To view more rows, export the report to Excel or use the printable view.” Charts cap at 2,000 groups in Lightning Experience, a dashboard widget “can calculate up to 1,000 groupings,” and “a matrix report with one summary formula can show a maximum of 400,000 summarized values.” Reports “time out after 10 minutes” by default, and an export “with fewer than 100,000 rows and 100 columns can also time out due to performance issues” (Limits and Allocations).
The dashboard running user is the structural problem. A dashboard declares a dashboardType of SpecifiedUser — “all users see data at the access level of one specific running user” — LoggedInUser — “each logged-in user sees data according to his or her own access level” — or MyTeamUser, plus a runningUser naming “the user whose role and sharing settings are used to determine the data shown” (Metadata API — Dashboard). In the UI these are “Me,” “Another person,” and “The dashboard viewer,” the last of which “is called a dynamic dashboard because it changes based on the viewer’s privileges” (Configure Dashboard Data Visibility).
Salesforce documents the consequence itself: “A dashboard viewer sees data based on the assigned privileges and access of the dashboard’s running user,” and viewers may see more than they otherwise would — “if the running user is an admin or an internal user, viewers could see personally identifiable information (PII)” (Dashboard Data Visibility Considerations). The same shape appears in report subscriptions, where “Run Report As” may name another person and recipients “see the same report data as the person running the report. It’s possible that they see more or less data than they normally see in Salesforce” (Subscribe to Reports in Lightning Experience).
The safe mode is the metered one. Viewer-scoped dashboards are capped per org: “Developer Edition: up to 3 dynamic dashboards; Enterprise Edition: up to 5; Unlimited and Performance Editions: up to 10” (Dynamic Dashboard Limits). An org that reaches ten and needs an eleventh either buys capacity or builds the dashboard with somebody’s access baked into it. That is an allocation limit steering an architecture decision, and it steers it in the less safe direction.
Dashboard filters lose their values on the way to the inbox. Each filter allows up to 50 values, cannot use custom summary formulas or bucket fields, cannot be added to dashboards containing Visualforce or s-control widgets, and — plainly stated — “filters aren’t applied in dashboard subscription emails” (Filter a Dashboard).
Subscriptions are hard-capped per user: 15 report and 15 dashboard subscriptions on Unlimited and Performance, 7 and 7 on other editions, counted separately, and the cap cannot be increased by Salesforce Support (Report or Dashboard Subscription Limit Reached). Subscription attachments cap at 15,000 rows and 30 columns.
Reporting snapshots are the trend-history mechanism, and they are tightly bounded: up to 2,000 rows inserted per run, 200 runs stored, and 100 source-report columns mapped (Limits and Allocations).
Above all of it sits CRM Analytics, a separately licensed product, as the escape hatch for volumes and analysis the reporting engine cannot serve. The Reports and Dashboards REST API itself “is part of standard Salesforce functionality and doesn’t require a separate add-on or license” (Reports and Dashboards REST API) — the API is free; the capacity is not.
Where CAOS is genuinely better:
- The report declares its own traversals, so there is no report-type artifact, no four-object join ceiling, and no rule about which join may follow which.
- Shape is derived from the grouping spec, so there is no format enum, no conversion between formats, and no feature that exists in one shape and not another.
- Blocks are first-class, carrying buckets, row-level formulas, charts, and dashboard filtering — the exclusions attached to the joined format do not exist, because the joined format does not exist.
- No running user. Every widget re-runs under the viewer, so a dashboard cannot be a channel for someone else’s access, and the viewer-scoped mode is not metered because there is nothing cheaper to fall back to.
- Elevated numbers are published records with an owner, a grant, a timestamp, and a history, instead of a live query executed under a borrowed identity.
- Dashboard filters are bound parameters spliced into the compiled query, so they survive into subscriptions, URLs, and exports.
- Formulas are ordinary pure expressions in a dependency graph — unlimited, any field type, currency-aware, and able to reference each other and reference buckets.
- Buckets are expressions, which removes three separate caps and the bucket-versus-formula compatibility matrix along with them.
- One filter expression replaces a criteria list plus a filter-logic string plus cross filters plus subfilters, in the same grammar as every other expression on the platform.
- Cost is estimated at author time. A report that will be too expensive is refused with its plan rather than shipped and discovered.
- Exports are jobs, so there is no ten-minute synchronous wall and no row ceiling on the file.
- Field-permission removal is explicit. A column the reader cannot read is removed along with everything derived from it, and the removal is stated — never nulled into an aggregate that then reads as zero.
Parity: the report, dashboard, subscription, and snapshot quartet; grouped, cross-tabulated, and joined shapes; bucketing; row-level and summary formulas; cross filters; charts bound to reports; dashboard filters; conditional delivery; folder-based sharing; and scheduled delivery with attachments. This is the right set of capabilities, and CAOS ports it rather than reinventing it. The 200-column and 4 × 3 grouping caps are deliberate copies in spirit — the incumbent’s instinct that a report has a useful upper bound is correct, even where the specific numbers are artifacts of its implementation.
Costs and risks:
- Running every report as its reader is the whole bet, and it costs cache hit rate. The access fingerprint recovers most of it in organizations with a small number of distinct access shapes, and recovers nothing where access is nearly unique per user. That case is real, it is slower, and the honest answer is that the alternative is showing people other people’s data.
- Snapshots move a security decision onto an author. A published metric is a deliberate disclosure, and a careless one is as dangerous as a badly chosen running user. Snapshot components need review like sharing rules, and the review surface has to exist before the first one ships.
- Author-time refusal will reject reports people want. “This report will not save because it groups on an unindexed 400,000-value column” is correct, unwelcome, and the source of a support conversation on day one.
- Grouping-set cardinality is a real ceiling. Twenty grouping levels in one pass is fine; the growth curve past that is why the cap is low, and someone will want a fifth dimension.
- Column removal changes what a report shows, per reader. Two people can open the same report and see different columns. It is stated on screen, and it still means a shared screenshot may not match what the next person sees.
- The one-expression filter needs a two-way builder. Point-and-click users must get a filter UI, and it must round-trip to and from the expression. Expressions the builder cannot render fall back to text editing, and that boundary is visible to users who did not choose it.
- A closed chart catalog will lack somebody’s chart. Refusing a forty-slice donut is right; refusing a dual-axis combination chart a customer’s board deck has used for a decade is a real cost, and the answer — export the data and chart it elsewhere — is not the answer they wanted.
- No separate analytics product means no separate analytics capacity. There is no paid tier to escape into when a tenant’s volume genuinely exceeds what live queries over normalized tables can serve. Cached results and snapshots are the answer, and they are a smaller answer than a purpose-built columnar engine.
- Predictive and learned analysis is absent. No forecasting, no anomaly detection, no cross-tenant learned relevance. The AI layer can ask questions of a report; it does not replace a modelling product.
Metadata & deploy representation
Section titled “Metadata & deploy representation”| Component | type |
Body |
|---|---|---|
| Report | report |
source, paths, filter, columns[], groupBy[], summaries[], detail, blocks[], chart, freshness |
| Dashboard | dashboard |
widgets[], layout[], filters[], freshness, optional warm |
| Subscription | report_subscription |
report or dashboard, schedule, runAs, recipients[], filterValues, deliverWhen, attach[] |
| Snapshot | report_snapshot |
report, schedule, runAs, into, map, retain |
| Folder | folder |
label, parent, type — governed by ordinary record access |
There is no report_type component, no runningUser field, and no format field. Each absence is load-bearing, and each is enumerated here so a migration knows to expect it.
Reports and dashboards are metadata, not data: components in the repository that diff, deploy transactionally, version, and promote between environments like anything else. Subscriptions created by end users are the exception — a user subscribing to a report from the UI creates a record, not a component, the same way a saved search or a role assignment is record data. A subscription that ships with an application, such as the Monday morning operations report every tenant gets, is a component.
Every report is a node in the dependency graph. A field rename rewrites the expressions that reference it; a field delete routes through safe-delete and names the reports that would break; and a change to field permissions that would remove a column from a report’s run is reported in the deploy diff, because “this deploy makes the margin column disappear from four dashboards for everyone outside Finance” is exactly the effect nobody discovers until Monday.
Deploys that change only presentation — labels, chart type, column order — are metadata-only and take effect at the generation flip. Deploys that add a grouping or a filter on an unindexed column carry an index migration and run expand → migrate → contract. Snapshot components that write into a new object carry that object’s schema change like any other.
Salesforce Metadata API analogs, for migration mapping: Report (format, groupingsDown, groupingsAcross, aggregates, buckets, filter, chart, rowLimit, timeFrameFilter), ReportType (baseObject, join, sections, columns), Dashboard (dashboardType, runningUser, dashboardGridLayout, componentType), and the report and dashboard folder types.
Sources
Section titled “Sources”- Reports and Dashboards Limits and Allocations — Salesforce Help — the 2,000-row display cap and the export/printable-view workaround; 2,000 chart groups in Lightning Experience; 1,000 groupings per dashboard widget; 400,000 summarized values in a matrix with one summary formula; 20 field filters, 3 cross filters, 5 subfilters; 5 formulas per report; 10 custom summary formulas per joined block and 10 cross-block; 5 joined blocks of 100 columns; the 20,000-row joined export and printable view; 25 dashboard widgets, of which 20 charts and tables, 3 images, 25 rich text; 5 bucket fields, 20 buckets, 20 values; 60 object references and 1,000 fields per custom report type; the more-than-20-objects column error; 15,000-row and 30-column subscription attachments; reporting snapshots at 2,000 rows, 200 runs, 100 mapped columns; the 10-minute report and export timeouts; the 1-minute dashboard refresh interval and the 100 / 3,000 report-chart refresh caps.
- Metadata API —
ReportType— “A maximum of four objects can be joined in a custom report type”; no outer join after an earlier outer join;baseObject,join,sections,columns,category. - Metadata API —
Report— the fourformatvalues; up to 3groupingsDownfor summary reports and 2 for matrix; up to 2groupingsAcross;aggregates, buckets,ReportFilter,chart,rowLimit,timeFrameFilter. - Metadata API —
Dashboard—dashboardTypevaluesSpecifiedUser,LoggedInUser, andMyTeamUserwith their exact definitions;runningUseras “the user whose role and sharing settings are used to determine the data shown”; the grid layout; the breadth of thecomponentTypechart catalog. - Configure Dashboard Data Visibility in Lightning Experience — Salesforce Help — the “Me,” “Another person,” and “The dashboard viewer” options, and the definition of a dynamic dashboard.
- Dashboard Data Visibility Considerations — Salesforce Help — “A dashboard viewer sees data based on the assigned privileges and access of the dashboard’s running user”; the PII exposure warning; the View My Team’s Dashboards and View All Data behaviors; license-type restrictions.
- Dynamic Dashboard Limits: How to Find, Manage, and Request — Salesforce Help — 3 dynamic dashboards on Developer Edition, 5 on Enterprise, 10 on Unlimited and Performance.
- Report or Dashboard Subscription Limit Reached — Salesforce Help — 15 report and 15 dashboard subscriptions per user on Unlimited and Performance, 7 and 7 elsewhere, counted separately, and described as a hard cap.
- Filter a Dashboard — Salesforce Help — 50 values per filter; bucket fields and custom summary formulas unsupported; joined-report chart widgets unfilterable; “filters aren’t applied in dashboard subscription emails.”
- Get the Most Out of Row-Level Formulas: Tips, Limits, and Limitations — Salesforce Help — 2 row-level formulas per report; 5 unique fields each; references to buckets, summary formulas, and other row-level formulas disallowed; unsupported field types; default-currency-only behavior; unavailable on joined reports, in snapshots, in Classic, and in the Apex API.
- Report Formats in Salesforce Classic — Salesforce Help — the four formats, tabular reports being unusable in dashboards unless rows are limited, and joined reports being edition-gated.
- Subscribe to Reports in Lightning Experience — Salesforce Help — the “Run Report As” options, up to 5 aggregate conditions, the Formatted Report and Report Details attachment formats, and the warning that recipients may see more or less data than they normally see.
- Dashboard Component Types — Salesforce Help — chart, gauge, metric, table, Visualforce page, and custom s-control components with their stated purposes.
- Reports and Dashboards REST API — Salesforce Developers — the API “is part of standard Salesforce functionality and doesn’t require a separate add-on or license.”
GROUPING SETS,CUBE, andROLLUP— PostgreSQL — multiple grouping levels computed in a single pass, and theGROUPING()function that labels which level a result row belongs to.- Row Security Policies — PostgreSQL — policies applied on every access path, including aggregate queries, which is what makes the record-access plane unforgettable inside a report.