Skip to content

DRAFT ADR: Reporting and dashboards

  • Status: Draft — not ratified, and deliberately not numbered. Gated on OI-001, OI-002, OI-003.
  • Date: 2026-08-17
  • Deciders: El, Lachlan

Three inputs are unresolved and each could change the decision: AroFlo data completeness, Metabase behaviour under a memory limit, and the AGPL position on embedding. Ratify — and assign a number — after the spikes, not before.

Context

Two distinct things get called "reporting" and they must not share a build:

  1. Octopodia's reporting — proof of retainer value, for renewal conversations.
  2. The client's operational dashboard — their live numbers, on their data. A product they would want whether or not we automate anything.

Greenhills has asked for the second: scheduling, payments and approvals, plus average duration by job type. AroFlo's own reporting is reportedly not comprehensive, and AroFlo↔Xero is disconnected, so the defensible value is the cross-system join rather than better charts over one source (OI-006).

Proposed decision

Boundary. Aggregate metrics leave a tenant; rows never do. Each tenant stack pushes counters — action counts, approval latency, exception rate — to the control-plane registry on a schedule. Cross-tenant reporting queries the registry, not N databases. ADR-0006 and ADR-0008 survive intact and no warehouse is built.

Data architecture — layers in the tenant's own Postgres, no warehouse:

Layer Contents Reuse
Landing Raw as-pulled, immutable, timestamped. Kept permanently. Universal shape
Mapping Source-specific translation — the only place AroFlo-isms may exist Per source
Modelled Canonical: job, job_event, invoice, payment, approval, customer, technician Universal
Serving Materialised views, one per dashboard metric Metric library universal; definitions per client

Keeping landing permanently is the decision that pays for itself: when a client redefines "completed" in month four, the rebuild is local and free rather than a re-pull against AroFlo's 2,000 calls/day cap. Backup posture for each layer is set by ADR-0026, which is a different question from retention on the box.

Reproducibility. Connectors are built per source, not per client. The canonical model is universal — every field-service SMB has those entities, so Simpro or ServiceM8 means a different connector and the same model. Taxonomy, thresholds and metric definitions are config rows, not code (ADR-0003). Each tenant is version-pinned to a model + metric-library version; upgrades are an Ansible run.

Test to apply: could this tenant be rebuilt from the repo and its config rows alone? If not, that is drift, and drift turns ten clients into ten products.

Presentation — two layers. - Headline metrics (~six numbers) rendered natively in the client review app, in Octopodia brand. Small, fixed, decision-grade. This is the felt-bespoke surface. - Metabase embedded beneath it for depth and self-serve exploration, at /insights under the tenant's app hostname (ADR-0022 §3). One instance per paying dashboard client (ADR-0023 §5–7). - Static embedding (free tier, signed JWT). Interactive embedding is Pro-and-above and bills per signed-in viewer, which is fatal across N tenants. The free tier carries a "Powered by Metabase" badge — acceptable because it sits on the depth layer, not the daily view. - Access control is enforced in our app: the backend decides which dashboards to sign a JWT for, based on the user's role in the review app. Metabase manages no users and the client never sees a Metabase login. Per-tenant instances mean no data sandboxing or row-level security is needed — the instance is the boundary.

Bespoke, in three tiers: - Base pack — every field-service client. Versioned, upgraded centrally, provisioned via the Metabase REST API from definitions committed in the repo. - Catalogue — proven cards, switched on per tenant by config. Built once, reused. - Bespoke — one client only, in their config. Priced as build plus maintenance (OI-024).

Rule: the second time a client asks for something, it moves from bespoke to catalogue. Bespoke logic goes in the serving views; bespoke presentation is not sold, because Metabase theming is shallow on the free tier.

Fact vs model. "412 invoices processed, 9 exceptions" is fact. "38 hours returned" is a model using a minutes-saved figure agreed with the client at Blueprint (OI-009). Present them differently.

Sequencing. Read-only dashboard first, on real data, for a few weeks before any write-back to Xero. Reading their data wrong is embarrassing; writing to their accounting system wrong is expensive. A visibly wrong dashboard also proves the sync on day one instead of month three.

Open before ratification

See OI-001 to OI-009 and OI-029 to OI-031 in docs/OPEN-ITEMS.md.

Watch item — not now

DuckDB-WASM + Parquet (OI-033): browser-embedded analytical database over static Parquet exports. No backend, no JVM, no AGPL question, no per-tenant CVE patching. Costs: the whole file lands in the browser so row-level access control is impossible; snapshot not stream; the charting layer becomes our build. Strong candidate for the native headline layer after Greenhills ships — the serving views export to Parquet either way, so nothing is wasted by waiting.