Skip to content

Reports, Analytics & Portal — views, Metabase, public surfaces, portal pages

This subsystem is the membrane between a declared application and its two outward audiences: citizens, who experience the SvelteKit portal at portal/, and operators, who measure the app through Metabase analytics and PDF reports. Nothing here is hand-coded per vertical. A surface lights up because an operator authored a row — a public_entity_surface, a portal_page, a report_template, or a views: block in a sheet — and the BFF generates the running experience from it. It is the load-bearing trust boundary of the platform: the same resolve_grants engine that authorizes staff (see Authorization & UMS) is re-pointed here at anonymous and citizen principals, and a single authoring act — publishing a portal page — emits the ACL rules that make the public read legal. Confirmed: the authoritative twin-gate contract is stated in the module docstring at admin-api/app/routers/public.py:6-27.

Data model

NamePurposePlaneKey fields
public_entity_surfacePer-entity citizen-read disclosure policy carrying all axes (rows, columns, owner, facets, child-expand, lane). Its presence IS the Gate-A citizen-read declaration.Vocabularyentity_type, published_states[], status_field, public_fields[], owner_field, owner_identity_type, filterable_fields[], searchable_fields[], public_children{}, source_kind(entity|view), is_active
portal_pageRoute→template+overrides binding for one citizen page; publishing emits citizen ACLs.Templateroute, match_order, template, overrides{}, embedded_slots[], application(FK), is_active, tenant_id
portalPer-tenant portal chrome: brand/logo/theme, site_template, nav, locales.Tenantcode, name, logo, theme{}, site_template, enabled_verticals[], locales[], nav_links[], legal_refs[]
page_templateShared slot/block structure a portal_page references; slots[].defaults[] carry block bindings.Vocabularycode, slots[]{name, defaults[]{binding, props}}, rhythm
action_surfaceSurface taxonomy registry; audience=citizen rows define the runtime set Gate A checks (60s TTL-cached).Vocabularycode, audience, is_active
report_templateOperator Jinja2 HTML+CSS bound to an entity type, rendered to PDF by WeasyPrint.Templatecode, body_html, page_css, output_format, entity_type_code, extends(FK self), organization(FK), expand[], is_active
views (ontology view specs)Aggregate/KPI definitions as native ontology views — the ONLY sanctioned aggregate source.Vocabularycode, kind, aggregate, group_by[], filter, select[]

How it's declared

Every output surface is authored as data. Citizen disclosure — a public_entity_surface row, declared via a sheet public_surfaces: block (civic sheet:1579) or an editor, names published_states/public_fields/owner_field/source_kind; its mere presence opens Gate A for that entity. Portal pages — a portal_pages: block (civic sheet:1708) or the portal-page editor; routes are authored relative and the BFF mounts them under the app's route_namespace (_compose_namespaced_route, public.py:2882). Portal chrome — the single portal row. Reportsreport_template rows authored in /settings/report-templates (Jinja2 HTML+CSS) or seeded via a reports: block (civic sheet:3437); the download button auto-appears because GET /reports?entity_type_code= (reports.py:219) is config-driven. Staff analytics — a views: block plus Metabase cards (scripts/metabase_civic_cards.py) referenced by dashboard_config.embeds[].dashboard_id. Citizen aggregates — a public_entity_surface row with source_kind: view over a tenant_id-grouped view. The Tier-0 dashboard_config editor (DashboardEditor.svelte, PR #506) authors dashboards on the shared design-system renderer.

How it's provisioned

Vocabulary-plane types (public_entity_surface, action_surface, page_template, view specs) import once under tenant system. The template plane (template_municipality) holds the forkable worked example: report_template rows (forkable:true, copied + org-stamped on fork via fork_plan.derive_tenant_wide_plan, reports/seed_catalog.py), public_entity_surface policy rows (written under the binding tenant post-#92), and portal + portal_page rows. A one-shot full-catalog fork copies and FK-rewires these into each tenant; afterwards a tenant admin edits THEIR copy with the same editors and zero special-case code. The strict reader reads policy rows under the request's resolved tenant (get_public_surface_config cache keyed by (base_url, tenant), public_disclosure.py), so a tenant with no forked row gets the disclosure floor, never another tenant's policy. Metabase is provisioned out-of-bandmetabase_civic_cards.py runs against a live Metabase, and the resulting dashboard_id is hand-wired into the sheet (a literal, e.g. civic dashboard_id: 40).

Extension points (the growth vector)

  • Expose a new entity to citizens: add a public_entity_surface row — Gate A opens, the projection scopes columns, no BFF code.
  • Add a citizen aggregate: declare an ontology view that group_by tenant_id, add a source_kind:view surface row — _serve_public_view (public.py:375) serves it strictly.
  • Add a staff KPI dashboard: declare the view, add a Metabase card, wire dashboard_id into dashboard_config.embeds[].
  • Add an operator PDF: author a report_template binding entity_type_code; child collections via expand.
  • Add a portal page: add a portal_pages: entry; publishing emits citizen ACLs automatically, nav is derived from the page.
  • Narrow a citizen list: extend filterable_fields/searchable_fields — keys minted from policy, narrowing-only.
  • The create-vertical skill scaffolds the public_surfaces/views/portal_pages/reports/dashboard blocks together.

Invariants

  1. Gate A is a property of the endpoint, not the principal — it stays enforced even when a bearer is present, so authenticating on the portal can never reach staff surfaces (public.py:11-13, confirmed in docstring).
  2. Public reads are fail-closed: UMS unreachable → 503; no matching rule → 1=0 deny-all → empty page, never the whole table (public.py:16-18).
  3. An authed citizen never sees less than anonymous — the graduated UNION (user-grants ∪ anonymous-public-grants) guarantees it; CITIZEN BASE mirrors PUBLIC READ constraints exactly (acl_compile.py, confirmed: CITIZEN BASE uses the same status_constraint).
  4. An empty published-state set is REJECTED at compile, never compiledEmptyPublishedStatesError (acl_compile.py); an unconstrained grant on the anonymous principal resolves as unrestricted = whole-entity exposure (confirmed at file).
  5. The disclosure floor is fail-closed to {id, created_at, updated_at} + status_field (confirmed DISCLOSURE_FLOOR frozenset, public_disclosure.py).
  6. owner_field (a KC sub) crosses ONLY on rows the caller owns — sub-equality even if listed in public_fields (confirmed project_public_row: owner survives only when row[owner_field] == caller_sub).
  7. Declared facets can only INTERSECT (narrow) the UMS WHERE, never widen it (public_disclosure.py).
  8. A publicly-surfaced view MUST group_by tenant_id — the BFF injects tenant_id, never discloses it, and a view missing the column 422s upstream → fail-closed 404 (confirmed _serve_public_view docstring, public.py:375).
  9. Reader and writer of citizen ACLs both resolve under admin-bff with the per-tenant principal, so compile-on-save and the public reader agree by construction (D13).
  10. Compile-on-save is pre-commit and fail-closed — any failure rejects the row write; no half-published surface (PortalPageWriteHooks._emit, write_hooks.py).
  11. Report rendering is SSRF-locked (data:-only url_fetcher) and Jinja-sandboxed (__-probe → hard 422); template scope and record authz both reuse entity_proxy._check_row_or_raise (reports.py).

Gaps & open edges

  • Citizen-aggregate views bypass BOTH gates (_serve_public_view): the surface row's public_fields is the sole disclosure decision. A mis-authored source_kind:view row leaks aggregate columns; the only guards are required tenant_id injection and public_fields-required. Sharp, deliberate trust concentration on one row.
  • Reports have NO delivery ledger despite the "delivery" framing — they render synchronously to PDF bytes; report_template only supports output_format=pdf. The only ledger is notification_delivery, which belongs to Notifications & Side-Effects, not here.
  • Metabase provisioning is out-of-band and manualdashboard_id is a hand-wired literal; there is no fork-time or import-time per-tenant reconciliation. Tenant scoping rests entirely on the LOCKED tenant_id signed-embed param.
  • Frontend portal-config default is a demo fallbackportal-config.ts still hardcodes siteTemplate report-portal, enabledVerticals [occurrences], and the Valongo brand tokens; the data overlay is wired but the built-in default is not vertical-neutral.
  • Drift tolerance: deleting a portal_page does NOT revoke its citizen ACL rules, and a published_states change leaves the old (deny-narrower) rule; both rely on a reconciler referenced but not co-located here. Orphaned rules are tolerated — a correctness-vs-cleanliness debt.
  • Per-app citizen ACL migration is mid-flight (#409/#428/#433): reads flip to the entity's owning per-app namespace only for single-owner entities, falling back to admin-bff for 0/ambiguous owners and on any error; the apply/write path is explicitly NOT yet flipped — a dual-namespace state.
  • The map public lane is partial — WFS GeoJSON gets CQL tenant injection, but tiles build params from scratch so injected CQL can't ride them; external/proxy layer sources are not publicly exposable in v1.
  • Python↔TS golden-fixture coupling: the bound-entity ACL emission depends on portal/resolve.py and portal/src/lib/templates/catalog.ts staying in lockstep; divergence reintroduces the bug silently.

Atelier — Platform Specification. Internal canonical reference.