Skip to main content

Reporting & Analytics — How it works

Overview

The reporting module is where the platform's reports, aggregations, charts and dashboards will live: the report builder, the executive KPI catalog, and the scheduled runs that feed them. It is a domain module (com.zhenus.uhp.api.reporting), so it imports only common and exchange and reaches platform engines over Feign — the same call whether it is bundled in the single jar or split into its own service later. Design: MILESTONE11_PLAN.md, CORE_PLAN.md §15.1/§15.5.

workforce is the structural reference. MILESTONE11_PLAN.md retracts the original billing of this module as "the platform's first real domain module": workforce shipped first, and reporting follows the pattern rather than establishing it.

Reporting now builds, runs, schedules and displays reports. M11-002 through M11-006 have landed: the module owns a schema, compiles a validated query tree to SQL, executes it against a fenced read-only path, stores and re-serves snapshots, regenerates them unattended, and composes the results into charts and dashboards. What exists today:

TicketDeliversStatus
M11-001Maven module, /api/v1/reporting namespace, module status endpoint, architectural fencedone
M11-002Data-access strategy: one deployment flag, report_source allow-list, mandatory scope filterdone
M11-003Report definitions + the query AST compiler — the module's only SQL producerdone
M11-004Execution, snapshots, scope-keyed cachingdone
M11-005Scheduled runs, claimed in the database so N instances produce one rundone
M11-006Charts + dashboards, with the report binding re-checked on every renderdone
M11-007ASmall-cell suppression — what keeps an AGGREGATE source aggregatedone
M11-007BThe seeded executive KPI catalogblocked on M11-014/015
M11-014Joins and computed columns (PO decision D21)in progress
M11-015Raw SQL reports, super_admin only (PO decision D21)not started
M11-016Coded observations resolve to concept namesnot started
M11-007Seeded executive KPI catalognot started
M11-010Milestone gatenot started

Frontend counterparts shipped alongside: FE-290 (report builder), FE-291 (results and run history), FE-292 (schedule administration), FE-293 (chart builder and dashboard composition).

Data model & ownership

Reporting owns the reporting schema, created by reporting.db.changelog-master.yaml and registered in app's platform.db.changelog-master.yaml. It holds report_source, report_definition, report_snapshot/report_snapshot_row, report_schedule/report_schedule_run, and — from M11-006 — chart_definition, dashboard and dashboard_widget. M11-001's temporary JPA/Liquibase auto-configuration exclusions are gone, and ReportingApplication is an ordinary module launcher again.

⚠ Two data paths, and they are not interchangeable

PathPoolUsed for
Reporting's own tablesthe ordinary platform DataSourcereport_source, and later definitions, snapshots, dashboards
Other modules' datacoreDataSourcebulk aggregation, reached only through ReportSourceQueryExecutor

coreDataSource is contributed by common's CoreDataAccessConfiguration and one flag decides its shape (PO, 2026-08-19):

uhp.core-data-access.standaloneWhat the module gets
false (default)The bundled platform pool. Same database, separate schema. No second credential exists to manage, rotate or leak.
trueA separate read-only Hikari pool from url/username/password. Each missing value fails fast at boot — a half-configured split deployment that starts is one reading the wrong database.

Migrating a schema out to its own database is then a configuration change, not a code change. This is the platform-wide pattern for cross-module database access, not a reporting special case — see CLAUDE.md, "Database access across a module boundary".

report_source — an allow-list, not a catalogue

A report may name only a relation somebody deliberately registered. ⚠ Read-only does not help here: it constrains writes, and nothing about it narrows what can be read. Each row carries the relation, the columns it must be filtered by, and how much a row reveals:

ColumnWhy it exists
source_schema + source_relationSchema-qualified, so a bare name cannot be re-pointed at another table
tenant_columnNOT NULL. A source that cannot be scoped is refused, never run unfiltered
facility_columnNullable — absent means the source is tenant-scoped only
source_kindRELATIONAL (real columns, including a DnD form's domain_engine.<module>_<table>) or OBSERVATION (concept-coded, where the concept's datatype picks the value column)
phi_classificationNONE | AGGREGATE | IDENTIFIABLE
required_permissionMandatory when IDENTIFIABLE; a CHECK constraint refuses the row otherwise

One classification decides three things — permission, materialisability, output handling — rather than three booleans, because three booleans can disagree: "not PHI but requires a permission and must not be cached" is a state somebody would eventually create by editing one of them, and the code would act on the nonsense.

Sources are global-capable: a tenant sees its own plus the platform's (seeded under the Global tenant), and its own shadows the platform's of the same code. ⚠ That widens which source may be named, never which rows come back — the tenant predicate is still built from the caller's own scope. Editing is strictly own-tenant, so one tenant cannot retire a platform source out from under every other tenant.

The executive KPI catalogue (M11-007B)

reporting/013 seeds the hospital-executive scorecard under the Global tenant: 11 further sources (billing, claims, staff, queues, bed placements, HL7 messages, audit/security events, break-glass grants, documents — all AGGREGATE-classified, tenant+facility scoped), 19 AST report definitions with a chart each, and the SHARED executive_scorecard dashboard. The full metric list — including the 12 blocked metrics and the prerequisite each is waiting on — is reporting/KPI_CATALOG.md. Two facts worth knowing before editing any of it: the AST catalogue is read from information_schema at run time, so a wrong column in a seeded AST fails on first run, not at migration — which is why SeedDriftGateTest executes every seeded definition on a fresh database; and seeded filters touch text columns (or IS_NULL/IS_NOT_NULL) only, because filter values bind as untyped strings.

Key rules & invariants

  • No core import, and no sibling domain-module import. ReportingArchitectureBoundaryTest refuses com.zhenus.uhp.api.core and each of workforce, imaging, hl7, fhir, lab, pharmacy. Reporting is the module most exposed to the sideways import — a report aggregates facts from all over the platform, so for any figure the shortest path is an import of whichever module owns it, and the pull is strongest when someone is under pressure to ship a KPI. ⚠ M11-002's read-only-database exception does not relax this. That exception is about SQL against approved objects; importing a core service or entity would couple this module to core's Java types and end the possibility of extracting it.
  • Raw JDBC is confined to ..reporting.source... An ArchUnit rule refuses java.sql, javax.sql and org.springframework.jdbc anywhere else in the module. It now has something to fence: if platform reads spread across services, the report_source allow-list and the mandatory tenant/facility filter become something each query author has to remember, and the one who forgets returns another tenant's rows. ⚠ M11-002's first draft put these classes in ..reporting.dataaccess.. and the rule failed the build, correctly. The package was renamed; the rule was not widened.
  • The scope predicate is added, never checked. ScopedSourceQueryFactory is the only place a report's SQL is assembled, and there is no path through it that produces a statement without the tenant filter. ReportScope cannot be constructed without a tenant, so "unscoped" is unrepresentable rather than merely discouraged. Every unscopable case refuses — including a facility-scoped request against a source with no facility column, which is refused rather than silently widened to the whole tenant. A caller that asked for one facility and quietly received every facility gets a wrong answer it has no way to notice.
  • A line list is never materialised. IDENTIFIABLE ⇒ requires a permission and may not be materialised (ReportMaterialisationPolicy). A matview of a line list is a standing copy of identifiable patient data in a second schema — outside the access guard, with its own refresh lag, its own place in backups and its own unanswered retention question. Aggregates may be materialised; they are not about one person.
  • An unreachable access-control service denies. ReportSourceAccessPolicy asks core rather than deciding locally, and a failed decision call is a denial. Reporting is bulk by nature — the one place where a moment of fail-open is not a single record.
  • Identifiers are validated even though they come from our own table. Schema, relation and column names are interpolated into SQL because they cannot be bound as parameters, and a source row is administrator-editable. SqlIdentifier accepts only [a-z_][a-z0-9_]*, at save time and at query time — so an administrator finds out on save, not when a report first runs.
  • Scheduling never goes on the module launcher. PlatformApplication excludes every module's *Application from the component scan, so @EnableScheduling there is inert in the shipped jar — the jobs simply never run and nothing says so (SCHED-002). An ArchUnit rule refuses the annotation anywhere in this module. M11-005's scheduled runs belong on a scanned @Configuration gated @Profile("scheduler").
  • Permissions are declared by ReportingModuleDescriptor. A @RequiresAccess code no descriptor declares can be granted to nobody, so the endpoint 403s for every caller including super_admin — silently, because a missing permission looks exactly like an unprivileged user. PermissionDeclarationGateTest in app fails the build on it.
  • The Feign interceptor is module-qualified. In the single jar every class name and every @Bean method name is a global identifier; workforce already declares callerContextForwardingInterceptor, so this module's is reportingCallerContextForwardingInterceptor. A duplicate compiles, passes every module test, and then refuses to start the assembled jar with ConflictingBeanDefinitionException.

PHI

The skeleton reads none. GET /api/v1/reporting/status describes the module, not its contents — it is the one reporting payload that could ever safely be logged, and nothing logs it, because the moment M11-004 puts report output through the same code path a line added "just for status" would be spilling PHI in bulk.

From M11-002 onward this module is a bulk PHI reader, which changes what the rules cost:

  • Holding reporting.report.read says a caller may run reports — never whose rows may appear in the output. Row-level scoping stays with the platform's access decision and M11-002's mandatory tenant/facility filter, applied by the policy rather than by each query author, so a missing WHERE clause returns nothing rather than everything.
  • A denial must be indistinguishable from a miss (SEC-011): a report that answers "forbidden" for one patient and "no rows" for another is an existence oracle over the patient register.
  • Nothing in this module may log a report row, a patient identifier or a result value.
  • A scheduled run has no caller, so the poller binds tenant and facility from the schedule row and clears them afterwards, and the Feign interceptor falls back to the module's SEC-018 service identity. ⚠ The service identity answers who is calling, never what may be read — widening the scope filter "because it is a background job" would make a scheduled report a cross-tenant read on a timer.
  • A chart is a pointer to a report, not a copy of its data, so the report binding is re-checked on every render (M11-006). Checking only when the chart was bound would let it outlive the authority that created it, and dashboards are shareable.

Permissions

CodeUsed byNotes
reporting.status.readGET /api/v1/reporting/statusModule identity
reporting.report.readSources, report definitions, results, schedules
reporting.report.writePOST/PUT/DELETE on sources, definitions and schedules⚠ Closer to a data-governance authority than a settings permission: a row added here makes a relation readable through a credential that bypasses the per-patient access guard
reporting.chart.readGET /api/v1/reporting/charts
reporting.chart.writeBind, edit and retire charts
reporting.dashboard.readList and open dashboards⚠ Grants no data: the report behind each chart is re-checked at render
reporting.dashboard.writeCreate dashboards, place widgets, retire

Charts and dashboards do not reuse the report codes. A ward manager who should see the dashboard somebody built for them has no business authoring queries; folding the two together would mean granting a report-authoring permission to let somebody look at a tile.

menuItems() is deliberately empty. The nav entry is the frontend's, and one shipped with a permissionKey but no matching AuthorizationCatalog route key makes the link vanish for everyone.

Configuration

Reporting still owns no reporting.* property — the cross-module credential deliberately did not become one, because the need is not reporting's alone. Two files exist and they do different jobs (CONF-002):

  • reporting/src/main/resources/application.yml — read only when the module runs standalone. The single jar resolves classpath:/application.yml to app's copy (one resource, no merge, no wildcard), so every key here must also exist in app's file or it reaches the deployable from nowhere. ModuleConfigShadowingTest fails the build on any that does not.
  • reporting/src/main/resources/uhp-module-defaults.yml — read in both deployments, appended at the lowest precedence by ModuleDefaultsEnvironmentPostProcessor. Anything in reporting's own reporting.* namespace belongs here. It is comment-only, and now records why: the credential is uhp.core-data-access.*, shared infrastructure owned by common, so it lives in application.yml where app's copy mirrors it.

REPORTING_APP_PORT (default 8090) applies to a standalone run only. REPORTING_DB_HOST/_PORT/ _NAME/_USERNAME/_PASSWORD fall back to the platform DB_* values and point at reporting's own schema.

The cross-module keys, in .env.example with no values:

UHP_CORE_DATA_ACCESS_STANDALONE=false
UHP_CORE_DATA_ACCESS_URL=
UHP_CORE_DATA_ACCESS_USERNAME=
UHP_CORE_DATA_ACCESS_PASSWORD=

Read-only-ness is the database's GRANT, never the property. An app-enforced read-only rule is one refactor from being bypassed, and the bypass would be silent — the readOnly property is a Hikari hint, not a control. And ⚠ GRANT SELECT alone is not enough: measured live, a SELECT-only role created a table on the first attempt, because PUBLIC may hold CREATE on the schema (PostgreSQL ≤14 ships that default on public; PG15 removed it, but an older server or a re-granted schema still has it). The full recipe is in CLAUDE.md, and ReadOnlyCredentialIntegrationTest proves it by reproducing the condition with a deliberate GRANT and then revoking it.

Endpoints

MethodPathPermissionNotes
GET/api/v1/reporting/statusreporting.status.readModule identity and available capabilities
GET/api/v1/reporting/sourcesreporting.report.readThe relations reports may read in this tenant
POST/api/v1/reporting/sourcesreporting.report.writeRegister a relation as readable
PUT/api/v1/reporting/sources/{id}reporting.report.writeUpdate a registered source
DELETE/api/v1/reporting/sources/{id}?reason=reporting.report.writeRetire a source; soft delete, reason required
GET/POST/PUT/DELETE/api/v1/reporting/reports[/{id}]reporting.report.read/.writeReport definitions — a validated AST, never SQL
POST/api/v1/reporting/reports/{id}/runreporting.report.readExecute; the cache is keyed by scope, so one tenant's rows can never be served to another
GET/POST/api/v1/reporting/reports/{id}/schedulesreporting.report.read/.writeUnattended regeneration; a schedule runs with its creator's scope
GET/api/v1/reporting/chartsreporting.chart.readThis tenant's charts
POST/PUT/DELETE/api/v1/reporting/charts[/{id}]reporting.chart.write⚠ Binding to an unreadable report is refused here and re-checked at render
GET/api/v1/reporting/dashboardsreporting.dashboard.read⚠ Always the caller's own resolved set — own + shared + role-scoped, merged per read. No parameter names a user, and none ever will: "show me user X's dashboards" is reconnaissance, not a feature
GET/api/v1/reporting/dashboards/{id}reporting.dashboard.readWidgets come back marked available or not, each with a reason
POST/api/v1/reporting/dashboardsreporting.dashboard.writePERSONAL takes its owner from the session, ROLE must name its role
POST/api/v1/reporting/dashboards/{id}/widgetsreporting.dashboard.writePlace a chart
DELETE/api/v1/reporting/dashboards/{id}?reason=reporting.dashboard.writeRetire; reason required

⚠ A widget the viewer may not see says so

Three things could happen when a shared dashboard carries a tile the viewer has no right to, and two of them are wrong:

Fail the whole dashboardPunishes the viewer for a widget that was never theirs
Render it emptyReads as "no data" — a different and false claim, and the one that does real damage when somebody reports the zero
Return it marked unavailable, with a reasonWhat we do

The unavailable widget also does not hand back the chart id it refused to render, so the payload cannot be mined for what lies behind the boundary. A dashboard the caller cannot see at all is a 404, not a 403 — indistinguishable from one that does not exist.

No endpoint accepts a tenant. ReportSourceDto has no tenantId field and that absence is the contract — the tenant comes from the signed session. A DTO carrying one would be a request to read another tenant's data using the scoping rule's own field name. Another tenant's source id answers "not found", never "forbidden", so the id space cannot be probed.

capabilities lists only what is genuinely servable — a client that reads a capability will then invoke it, so advertising one the server cannot honour would make it refuse work it had itself promised.

⚠ The module still serves no /catalog. The frontend once carried a speculative GET /api/v1/reporting/catalog for FE-228, deleted when the real backend turned out not to have it; the M11-007 KPI catalog is where that surface will actually be defined, written fresh against the real contract rather than resurrected from source history.

Where the code lives

reporting/
pom.xml common + exchange + JPA + Liquibase + postgres driver
src/main/java/com/zhenus/uhp/api/reporting/
ReportingApplication.java standalone launcher; excluded from the single jar's scan
config/ReportingModuleDescriptor.java module key, version, declared permissions
config/ReportingFeignContextPropagationConfig.java
controller/ReportingStatusController.java
controller/ReportSourceController.java allow-list administration
controller/ReportDefinitionController.java
controller/ReportScheduleController.java
controller/ChartDefinitionController.java
controller/DashboardController.java ⚠ no endpoint names another user
source/ ⚠ the ONLY package allowed to touch JDBC (ArchUnit)
ReportScope.java no tenant ⇒ cannot be constructed
ReportScopeResolver.java from the signed session; no overload takes a tenant id
ReportSourceDefinition.java
ScopedSourceQueryFactory.java the scope predicate is added here, never checked
ReportSourceQueryExecutor.java resolve → authorise → build; the only JDBC in the module
ReportSourceAccessPolicy.java asks core; an unreachable authority denies
ReportMaterialisationPolicy.java a line list is never materialised
SqlIdentifier.java identifiers are interpolated, so they are validated
query/QueryAstCompiler.java ⚠ the module's ONLY SQL producer
query/AstValidator.java validated at persist AND at compile
model/ReportSource.java + ReportDefinition + ReportSnapshot(+Row) + ReportSchedule(+Run)
model/ChartDefinition.java + Dashboard.java + DashboardWidget.java (M11-006)
model/enums/ + model/dto/ + model/mapper/
repository/ one per aggregate; DashboardRepository.findVisibleTo
resolves own + shared + role on EVERY read
service/ + service/impl/
helpers/ReportingConstants.java
src/main/resources/application.yml standalone only
src/main/resources/uhp-module-defaults.yml both deployments (CONF-002)
src/main/resources/db/changelog/
reporting.db.changelog-master.yaml ⚠ never `db.changelog-master.yaml` — collides with core's
reporting/changes/001-create-reporting-schema.yaml
reporting/changes/002-create-report-source.yaml
reporting/changes/003-seed-platform-report-sources.yaml
reporting/changes/004-create-report-definition.yaml
reporting/changes/005-create-report-snapshot.yaml
reporting/changes/006-create-report-schedule.yaml
reporting/changes/007-create-chart-and-dashboard.yaml
src/test/java/.../architecture/ReportingArchitectureBoundaryTest.java
src/test/java/.../integration/ReadOnlyCredentialIntegrationTest.java

Registration points outside the module: root pom.xml <modules>, app/pom.xml, PlatformApplication's excludeFilters, OpenApiGroupConfig (own group and the merged all scan), ApiV1Paths.REPORTING, app/src/main/resources/db/changelog/platform.db.changelog-master.yaml, .env.example, docs/feature-catalog.json.

Why it is this way

Reporting is a domain module shell awaiting M11. The Maven module proves boundary rules, Feign propagation, and module registration before KPI widgets and dataset APIs land in M11-007+. Finance KPIs will read ERP/posting data once M21 is complete — do not invent interim SQL in pharmacy or lab.

No JPA/Liquibase in the skeleton — reporting tables belong to M11's design, not ad hoc copies in the scaffold ticket.

Traps

Do not import core or sibling domain modules — datasets will pull via exchange.client.* and ERP/billing Feign clients only; ArchUnit ReportingArchitectureBoundaryTest enforces this.

M11-003 defines the real widget contract — resurrecting pre-M11 DTO shapes from git history will diverge from the merged M11 catalog; read M11 tickets before adding endpoints.

KPI sources cross modules — a finance widget that JDBC-reads erp.journal_line from inside reporting/ violates module boundaries even if it is faster in dev.

Small-cell suppression (M11-007A)

An AGGREGATE source is safe only while it is genuinely aggregate. The classification describes the source, not the query: "count of patients with diagnosis X, by ward and month" is an aggregate right up until a cell holds one person — at which point the report has re-identified them, using a source nobody needed a PHI permission to read.

Suppression runs before the rows are stored, not when they are served. A snapshot holding raw small cells is a stored disclosure: served again from cache, present in backups, and obliging every future read path to remember a rule it could forget.

A suppressed cell is "<5", never 0. Zero is a factual claim — "nobody here" — and a scorecard reading zero where the truth is three is worse than no number at all, because somebody acts on it.

Complementary suppression is the step that gets skipped, and skipping it makes the mechanism decorative. One hidden cell beside visible neighbours and a visible total is recoverable by subtraction, so a single suppressed cell takes the next-smallest with it.

Not suppressed, each for a reason: a true zero (nobody to identify, and blanking zeros hides the absences a safety scorecard exists to show); IDENTIFIABLE sources (line lists, already permission-gated, whose reader is authorised to see individuals); NONE sources (no patient data). And a stated limit rather than a hidden one: a SUM or AVG with no count cannot be policed by cell size, and the policy reports that rather than returning a clean result implying it checked.

Threshold: uhp.reporting.small-cell.threshold (default 5). Below 2 suppresses nothing and is refused at boot; disabling it needs an explicit uhp.reporting.small-cell.enabled=false.

Joins and computed columns (M11-014)

Shipped. M11-003 shipped with no joins on the reasoning that a join is how an allow-list is escaped. That reasoning is right; the prohibition it produced was not, and the PO overruled it — a builder that reads one table and computes nothing is not a reporting tool. The reasoning is satisfied differently instead:

The worryWhat answers it
A join reaches an unapproved tableBoth arms of a relationship are foreign keys to report_source — structural, not a rule somebody checks
A join condition is caller text in an identifier positionThe AST names a relationship code; the columns live in a registered row. No field accepts a condition
A joined arm escapes its tenant filterOne scope predicate per arm, emitted unconditionally — a join with one unfiltered arm is a cross-tenant read that passes every test written before joins existed

⚠ Every column reference is alias-qualified, even in a single-source report, because PostgreSQL resolves some ambiguities silently in favour of whichever side has the column.

Cross-schema joins need no special machinery — one database, schema per module, so clinical.observation joined to workforce.staff is an ordinary join. The bounds are the allow-list and the read-only credential's grants, not the schema boundary.

Computed columns are an expression tree, never a string: a node is a catalogue field, a bound literal, or a closed-enum operator over other nodes, and there is no fourth arm. ⚠ Division is RATIO, which carries its own NULLIF guard — plain division aborts the whole statement the one month a denominator is empty, so one empty ward would take down a hospital-wide report.

Raw SQL (M11-015)

The escape hatch, and deliberately narrow. M11-014 covers the ordinary cases; this covers the ones nobody anticipated.

A report carries a mode. AST is every report the builder produces and every report that existed before this; SQL is a hand-written statement.

ASTSQL
Re-validated against the live catalogueon write and every runnever — there is no tree
Who may author itreporting.definition.writesuper_admin only
Who may run itwhoever the source's scope admitssuper_admin only
Schedulableyesno
Chartable / dashboard-bindableyesno
Small-cell suppressionyesno — stated, not silently skipped

The mode defaults to AST when absent — every report written before M11-015 has a null mode, and defaulting the other way would reclassify the whole existing backlog as hand-written SQL.

super_admin, not a permission. A permission is something an administrator grants on a Friday to unblock somebody. super_admin already holds scope bypass, so the mode gives that caller no reach they did not have; a tenant analyst holding a reporting.raw-sql permission would gain reach that is entirely new, and the grant would look as routine as any other row in a role screen. A test proves a caller holding every reporting permission is still refused.

Authoring is gated, not only execution. Otherwise anyone with reporting.definition.write could store arbitrary SQL under an innocuous name and leave it for a super administrator to open and run — at which point the execution gate passes, because the super administrator pressed the button. Storing the statement is the whole attack. The stored side is checked too, so a report that is already hand-written cannot be edited by somebody who could not have created it.

The gate is PostgreSQL's planner, not a keyword denylist

RawSqlGate runs EXPLAIN (VERBOSE, FORMAT JSON) and reads the plan back. Any ModifyTable node anywhere in the tree refuses the statement, and every Schema + Relation Name pair is checked against the tenant's allow-list.

A denylist fails the first time somebody thinks of a shape nobody enumerated. WITH x AS (DELETE FROM … RETURNING *) SELECT * FROM x is the obvious one, and it is refused here because the planner reports a ModifyTable node — not because anything matched the word DELETE.

Three more refusals, each with a test:

  • A join reaching a relation outside the allow-list is refused and the relation is named.
  • ⚠ A statement touching no relation (SELECT 1) is refused rather than trivially passing. An allow-list check over an empty set is vacuously true, which is what a check that verifies nothing looks like from the inside.
  • An empty statement is refused before the database is troubled with it.

Behind the gate: a read-only transaction on the read-only credential, so a gate bypass still cannot write. And the full statement text is audited before it runs — a statement that reads a million rows and then times out has still read them, so auditing on success would lose exactly the runs worth seeing.

An absent AuthorizationGate refuses. "The platform could not answer" reads as "no", on both the authoring and the execution path.

Why it is not schedulable, chartable or dashboard-bindable

The restriction is the point, not an omission. Anything that regenerates unattended, or that other people see, must be re-validatable against the live catalogue. A hand-written statement cannot be — a dropped column would turn a nightly run into a stack trace nobody is watching, or somebody else's dashboard tile into an error, and "where did this figure come from" would stop having an answer.

Still open

M11-007B — the KPI catalogue seed.

M11-VERIFY — the reporting suite asserted the shape of emitted SQL, or drove mocked rows, and 195 tests were green over two live defects (see docs/ticket-log/M11-014A.md). The ReportingEndToEndIntegrationTest added since seeds its own data and asserts values; the rest of the module has not been re-examined on that basis.

Coded observations render as concept ids, not names (M11-016). ⚠ Until M11-007A they rendered as nullvalue_coded_concept_id was missing from the value coalesce, so a report said a question was unanswered when a clinician had answered it. The value now appears; making it readable needs a locale-correct join to concept_name, and the trap there is that locale_preferred is true once per locale, so ordering by it picks alphabetically across languages.