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:
| Ticket | Delivers | Status |
|---|---|---|
| M11-001 | Maven module, /api/v1/reporting namespace, module status endpoint, architectural fence | done |
| M11-002 | Data-access strategy: one deployment flag, report_source allow-list, mandatory scope filter | done |
| M11-003 | Report definitions + the query AST compiler — the module's only SQL producer | done |
| M11-004 | Execution, snapshots, scope-keyed caching | done |
| M11-005 | Scheduled runs, claimed in the database so N instances produce one run | done |
| M11-006 | Charts + dashboards, with the report binding re-checked on every render | done |
| M11-007A | Small-cell suppression — what keeps an AGGREGATE source aggregate | done |
| M11-007B | The seeded executive KPI catalog | blocked on M11-014/015 |
| M11-014 | Joins and computed columns (PO decision D21) | in progress |
| M11-015 | Raw SQL reports, super_admin only (PO decision D21) | not started |
| M11-016 | Coded observations resolve to concept names | not started |
| M11-007 | Seeded executive KPI catalog | not started |
| M11-010 | Milestone gate | not 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
| Path | Pool | Used for |
|---|---|---|
| Reporting's own tables | the ordinary platform DataSource | report_source, and later definitions, snapshots, dashboards |
| Other modules' data | coreDataSource | bulk 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.standalone | What the module gets |
|---|---|
false (default) | The bundled platform pool. Same database, separate schema. No second credential exists to manage, rotate or leak. |
true | A 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:
| Column | Why it exists |
|---|---|
source_schema + source_relation | Schema-qualified, so a bare name cannot be re-pointed at another table |
tenant_column | NOT NULL. A source that cannot be scoped is refused, never run unfiltered |
facility_column | Nullable — absent means the source is tenant-scoped only |
source_kind | RELATIONAL (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_classification | NONE | AGGREGATE | IDENTIFIABLE |
required_permission | Mandatory 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
coreimport, and no sibling domain-module import.ReportingArchitectureBoundaryTestrefusescom.zhenus.uhp.api.coreand each ofworkforce,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 refusesjava.sql,javax.sqlandorg.springframework.jdbcanywhere else in the module. It now has something to fence: if platform reads spread across services, thereport_sourceallow-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.
ScopedSourceQueryFactoryis the only place a report's SQL is assembled, and there is no path through it that produces a statement without the tenant filter.ReportScopecannot 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.
ReportSourceAccessPolicyaskscorerather 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.
SqlIdentifieraccepts 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.
PlatformApplicationexcludes every module's*Applicationfrom the component scan, so@EnableSchedulingthere 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@Configurationgated@Profile("scheduler"). - Permissions are declared by
ReportingModuleDescriptor. A@RequiresAccesscode no descriptor declares can be granted to nobody, so the endpoint 403s for every caller includingsuper_admin— silently, because a missing permission looks exactly like an unprivileged user.PermissionDeclarationGateTestinappfails the build on it. - The Feign interceptor is module-qualified. In the single jar every class name and every
@Beanmethod name is a global identifier;workforcealready declarescallerContextForwardingInterceptor, so this module's isreportingCallerContextForwardingInterceptor. A duplicate compiles, passes every module test, and then refuses to start the assembled jar withConflictingBeanDefinitionException.
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.readsays 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 missingWHEREclause 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
| Code | Used by | Notes |
|---|---|---|
reporting.status.read | GET /api/v1/reporting/status | Module identity |
reporting.report.read | Sources, report definitions, results, schedules | |
reporting.report.write | POST/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.read | GET /api/v1/reporting/charts | |
reporting.chart.write | Bind, edit and retire charts | |
reporting.dashboard.read | List and open dashboards | ⚠ Grants no data: the report behind each chart is re-checked at render |
reporting.dashboard.write | Create 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 resolvesclasspath:/application.ymltoapp's copy (one resource, no merge, no wildcard), so every key here must also exist inapp's file or it reaches the deployable from nowhere.ModuleConfigShadowingTestfails the build on any that does not.reporting/src/main/resources/uhp-module-defaults.yml— read in both deployments, appended at the lowest precedence byModuleDefaultsEnvironmentPostProcessor. Anything in reporting's ownreporting.*namespace belongs here. It is comment-only, and now records why: the credential isuhp.core-data-access.*, shared infrastructure owned bycommon, so it lives inapplication.ymlwhereapp'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
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /api/v1/reporting/status | reporting.status.read | Module identity and available capabilities |
| GET | /api/v1/reporting/sources | reporting.report.read | The relations reports may read in this tenant |
| POST | /api/v1/reporting/sources | reporting.report.write | Register a relation as readable |
| PUT | /api/v1/reporting/sources/{id} | reporting.report.write | Update a registered source |
| DELETE | /api/v1/reporting/sources/{id}?reason= | reporting.report.write | Retire a source; soft delete, reason required |
| GET/POST/PUT/DELETE | /api/v1/reporting/reports[/{id}] | reporting.report.read/.write | Report definitions — a validated AST, never SQL |
| POST | /api/v1/reporting/reports/{id}/run | reporting.report.read | Execute; the cache is keyed by scope, so one tenant's rows can never be served to another |
| GET/POST | /api/v1/reporting/reports/{id}/schedules | reporting.report.read/.write | Unattended regeneration; a schedule runs with its creator's scope |
| GET | /api/v1/reporting/charts | reporting.chart.read | This 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/dashboards | reporting.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.read | Widgets come back marked available or not, each with a reason |
| POST | /api/v1/reporting/dashboards | reporting.dashboard.write | PERSONAL takes its owner from the session, ROLE must name its role |
| POST | /api/v1/reporting/dashboards/{id}/widgets | reporting.dashboard.write | Place a chart |
| DELETE | /api/v1/reporting/dashboards/{id}?reason= | reporting.dashboard.write | Retire; 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 dashboard | Punishes the viewer for a widget that was never theirs |
| Render it empty | Reads 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 reason | What 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 worry | What answers it |
|---|---|
| A join reaches an unapproved table | Both 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 position | The AST names a relationship code; the columns live in a registered row. No field accepts a condition |
| A joined arm escapes its tenant filter | ⚠ One 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.
AST | SQL | |
|---|---|---|
| Re-validated against the live catalogue | on write and every run | never — there is no tree |
| Who may author it | reporting.definition.write | super_admin only |
| Who may run it | whoever the source's scope admits | super_admin only |
| Schedulable | yes | no |
| Chartable / dashboard-bindable | yes | no |
| Small-cell suppression | yes | no — 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
null — value_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.