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
| Name | Purpose | Plane | Key fields |
|---|---|---|---|
public_entity_surface | Per-entity citizen-read disclosure policy carrying all axes (rows, columns, owner, facets, child-expand, lane). Its presence IS the Gate-A citizen-read declaration. | Vocabulary | entity_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_page | Route→template+overrides binding for one citizen page; publishing emits citizen ACLs. | Template | route, match_order, template, overrides{}, embedded_slots[], application(FK), is_active, tenant_id |
portal | Per-tenant portal chrome: brand/logo/theme, site_template, nav, locales. | Tenant | code, name, logo, theme{}, site_template, enabled_verticals[], locales[], nav_links[], legal_refs[] |
page_template | Shared slot/block structure a portal_page references; slots[].defaults[] carry block bindings. | Vocabulary | code, slots[]{name, defaults[]{binding, props}}, rhythm |
action_surface | Surface taxonomy registry; audience=citizen rows define the runtime set Gate A checks (60s TTL-cached). | Vocabulary | code, audience, is_active |
report_template | Operator Jinja2 HTML+CSS bound to an entity type, rendered to PDF by WeasyPrint. | Template | code, 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. | Vocabulary | code, 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. Reports — report_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-band — metabase_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_surfacerow — Gate A opens, the projection scopes columns, no BFF code. - Add a citizen aggregate: declare an ontology view that
group_by tenant_id, add asource_kind:viewsurface row —_serve_public_view(public.py:375) serves it strictly. - Add a staff KPI dashboard: declare the view, add a Metabase card, wire
dashboard_idintodashboard_config.embeds[]. - Add an operator PDF: author a
report_templatebindingentity_type_code; child collections viaexpand. - 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-verticalskill scaffolds thepublic_surfaces/views/portal_pages/reports/dashboardblocks together.
Invariants
- 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). - Public reads are fail-closed: UMS unreachable → 503; no matching rule →
1=0deny-all → empty page, never the whole table (public.py:16-18). - 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 samestatus_constraint). - An empty published-state set is REJECTED at compile, never compiled —
EmptyPublishedStatesError(acl_compile.py); an unconstrained grant on the anonymous principal resolves as unrestricted = whole-entity exposure (confirmed at file). - The disclosure floor is fail-closed to
{id, created_at, updated_at}+status_field(confirmedDISCLOSURE_FLOORfrozenset,public_disclosure.py). owner_field(a KC sub) crosses ONLY on rows the caller owns — sub-equality even if listed inpublic_fields(confirmedproject_public_row: owner survives only whenrow[owner_field] == caller_sub).- Declared facets can only INTERSECT (narrow) the UMS WHERE, never widen it (
public_disclosure.py). - A publicly-surfaced view MUST
group_by tenant_id— the BFF injectstenant_id, never discloses it, and a view missing the column 422s upstream → fail-closed 404 (confirmed_serve_public_viewdocstring,public.py:375). - Reader and writer of citizen ACLs both resolve under
admin-bffwith the per-tenant principal, so compile-on-save and the public reader agree by construction (D13). - Compile-on-save is pre-commit and fail-closed — any failure rejects the row write; no half-published surface (
PortalPageWriteHooks._emit,write_hooks.py). - Report rendering is SSRF-locked (
data:-only url_fetcher) and Jinja-sandboxed (__-probe → hard 422); template scope and record authz both reuseentity_proxy._check_row_or_raise(reports.py).
Gaps & open edges
- Citizen-aggregate views bypass BOTH gates (
_serve_public_view): the surface row'spublic_fieldsis the sole disclosure decision. A mis-authoredsource_kind:viewrow leaks aggregate columns; the only guards are requiredtenant_idinjection andpublic_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_templateonly supportsoutput_format=pdf. The only ledger isnotification_delivery, which belongs to Notifications & Side-Effects, not here. - Metabase provisioning is out-of-band and manual —
dashboard_idis a hand-wired literal; there is no fork-time or import-time per-tenant reconciliation. Tenant scoping rests entirely on the LOCKEDtenant_idsigned-embed param. - Frontend portal-config default is a demo fallback —
portal-config.tsstill hardcodessiteTemplate 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_pagedoes NOT revoke its citizen ACL rules, and apublished_stateschange 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-bfffor 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.pyandportal/src/lib/templates/catalog.tsstaying in lockstep; divergence reintroduces the bug silently.