Files
Gebos/db/schema.sql
T
Lars Nolden b2a1056255
check / flake-check (push) Successful in 32s
deploy / deploy (push) Successful in 37s
schema v1
2026-07-03 17:05:43 +02:00

600 lines
28 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ============================================================================
-- GebOS Schema V1
-- Target: Postgres 17 + TimescaleDB
--
-- Scope: UVI pilot per § 6a HeizkostenV, methodology per UBA-Leitfaden
-- CLIMATE CHANGE 69/2021 (all six modules). Annual billing is a separate
-- epic and deliberately not modeled here.
--
-- Requirement IDs (UVI-DAT-*, UVI-FUN-*, UVI-NFR-*, UVI-MET-*) refer to the
-- Notion page "UVI Anforderungen (Pilot)", which is the source of truth.
--
-- Conventions
-- * lowercase snake_case everywhere — no quoted identifiers, ever.
-- * Surrogate UUID primary keys, except time-series tables where the
-- composite natural key carries meaning (see measurement).
-- * timestamptz for all instants; date for civil dates (tenancy, prices).
-- * jsonb, never json (binary, indexable, deduplicated keys).
-- * created_at on every table for ops forensics.
-- * ON DELETE RESTRICT as default policy: telemetry and tenancy data must
-- never disappear as a side effect of deleting a parent row. Deletions
-- are rare, deliberate, admin-level operations.
-- ============================================================================
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS btree_gist; -- exclusion constraint on occupancy
-- ---------------------------------------------------------------------------
-- Enums
--
-- Enums are used where the value set is fixed by law or by the product
-- itself and changes require a deliberate migration (which is a feature:
-- a new fuel type SHOULD force someone to also add its CO2 factor and
-- price rows). Where the value set is operational content that admins
-- curate at runtime (saving tips), a table is used instead of an enum.
-- ---------------------------------------------------------------------------
-- Drives CO2 emission factors (GEG Anlage 9) and cost estimation (Module 5).
-- One fuel per building is the pilot assumption. District heating fuel-mix
-- disclosure (§ 6a Abs. 3) belongs to annual billing; if it later requires
-- per-network fuel compositions, promote this enum to a table with a
-- composition child table — the FK sites below are the only touch points.
CREATE TYPE fuel_type AS ENUM (
'natural_gas',
'heating_oil',
'district_heating',
'heat_pump_electricity',
'wood_pellets',
'other'
);
-- The SEMANTIC kind of a reading. This is deliberately separate from the
-- physical unit: heating energy and hot-water energy are both kWh, yet the
-- UVI must never mix them (Modules 1 and 2 render them separately, and
-- UVI-FUN-14 treats them differently outside the heating period). The unit
-- says how a number is measured; the kind says what it means.
--
-- Diagnostic kinds (temperatures, error flags) are included because OMS
-- telegrams carry them anyway and they are the raw material for device
-- health monitoring — cheap to store now, expensive to backfill later.
CREATE TYPE measurement_kind AS ENUM (
'heating_energy', -- kWh, heat cost allocator / heat meter
'hot_water_energy', -- kWh
'hot_water_volume', -- m3; some installations meter volume and
-- convert to energy downstream
'cold_water_volume', -- m3; not needed for the UVI but commonly on
-- the same radio and trivially captured
'flow_temperature', -- °C, diagnostics
'return_temperature', -- °C, diagnostics
'error_flags', -- OMS status/error register
'other'
);
-- Two roles are enough for the pilot: tenants see exactly one apartment
-- dashboard (UVI-NFR-03), admins see the read-only overview of everything
-- (UVI-FUN-17). A role enum rather than a boolean because the role set is
-- known to grow (landlord read-only view is an announced post-pilot epic)
-- and because UVI-NFR-07 already treats "admin" as a first-class concept
-- with its own audit requirements.
CREATE TYPE user_role AS ENUM ('tenant', 'admin');
CREATE TYPE saving_tip_category AS ENUM ('heating', 'hot_water');
-- ===========================================================================
-- 1. BUILDING & TENANCY
--
-- The physical and legal world: buildings contain apartments, apartments
-- are occupied by tenants over time periods. Everything the UVI displays
-- is ultimately scoped by this layer — a measurement only becomes a
-- "tenant's consumption" through sensor -> apartment -> occupancy.
-- ===========================================================================
CREATE TABLE building (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Address is structured (not one text blob) because the admin
-- dashboard groups by building address (UVI-FUN-17) and because
-- postal_code is the likely input for automatic weather-station
-- assignment if that open decision lands on "auto".
street text NOT NULL,
house_number text NOT NULL,
postal_code text NOT NULL,
city text NOT NULL,
-- Coordinates exist for exactly one consumer: nearest-weather-station
-- resolution. They are NOT used to locate devices — device placement
-- is human-readable installer text on the device rows themselves.
lat numeric(9,6),
lng numeric(9,6),
-- The building's energy carrier. Chosen at building level (not per
-- apartment, not per measurement) because the boiler / district
-- heating connection is a building-level fact, and both the CO2
-- module and the cost module resolve their factors through it.
fuel_type fuel_type NOT NULL,
-- UVI-MET-03. The UBA/GEG comparison metrics (Bandtacho thresholds,
-- in-house kWh/m²) are defined against Gebäudenutzfläche (AN), a
-- technical energy-balance quantity nobody in this business has on
-- file. The sanctioned approximation is AN ≈ Wohnfläche × 1.2.
-- Stored per building, not hardcoded, so that a building with a
-- known real AN (e.g. from an energy certificate) can override it:
-- set area_factor = real_AN / sum(living_area_m2). Every specific
-- consumption in the app divides by (living_area_m2 * area_factor);
-- omitting the factor would inflate every building's apparent
-- consumption ~20% and shift Bandtacho classes.
area_factor numeric(4,2) NOT NULL DEFAULT 1.20
CHECK (area_factor > 0),
-- Which DWD station's heating degree days contextualize this
-- building's consumption (Module 1 footnote, UVI-FUN-07). Nullable
-- and manually assigned in the pilot: the auto-vs-manual assignment
-- question is an explicitly open decision, and the schema must not
-- presume its answer. A NULL here means the weather footnote renders
-- as "not available" (UVI-FUN-15) — never as a silent default station.
weather_station_id uuid, -- FK added after weather_station is defined
-- Building-level operational remarks: key-box code, basement access,
-- contact person. Device mounting locations do NOT belong here —
-- they live on the device rows (see gateway/sensor.installation_note).
note text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE apartment (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
building_id uuid NOT NULL REFERENCES building(id),
-- Human-readable unit designation ("2. OG links", "WE 04"). Unique
-- within a building so admin views and installer workflows have an
-- unambiguous handle.
label text NOT NULL,
-- UVI-DAT-01, and NOT NULL on purpose: the in-house comparison
-- (Module 2) is kWh per m² — an apartment without an area cannot
-- participate in the product's core comparison at all, so it is
-- invalid data, not missing data.
living_area_m2 numeric(7,2) NOT NULL CHECK (living_area_m2 > 0),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (building_id, label)
);
-- Application-level user profile. Identity — email address, magic-link
-- issuance, session state — is owned entirely by Supabase Auth (GoTrue)
-- in the auth schema (UVI-NFR-08: passwordless only). This table stores
-- only what the application adds on top. The PK IS the auth.users id
-- (same uuid, no separate surrogate) so that auth.uid() in RLS policies
-- joins directly without a mapping table.
CREATE TABLE app_user (
id uuid PRIMARY KEY, -- = auth.users.id
role user_role NOT NULL DEFAULT 'tenant',
display_name text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Tenancy periods (UVI-DAT-04). This table is the legal heart of the
-- data model, for two reasons:
--
-- 1. Mieterwechsel correctness: § 6a comparisons to the previous month /
-- previous-year month are only permitted against the SAME tenant's
-- consumption. The dashboard decides whether to render a comparison
-- by checking that both months fall inside one occupancy row. Without
-- periods, a new tenant would be shown (and judged against) their
-- predecessor's behavior.
--
-- 2. Privacy scope: a tenant may only ever see measurements taken during
-- their own tenancy (UVI-NFR-03). The RLS sketch at the bottom of this
-- file enforces that by joining measurement timestamps against this
-- table's date range — history before move-in is structurally
-- invisible, not just filtered in application code.
--
-- valid_to IS NULL means "current tenant". The exclusion constraint makes
-- overlapping tenancies for one apartment a constraint violation rather
-- than a bug class: the database itself guarantees that any timestamp
-- maps to at most one responsible tenant.
CREATE TABLE occupancy (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
apartment_id uuid NOT NULL REFERENCES apartment(id),
user_id uuid NOT NULL REFERENCES app_user(id),
valid_from date NOT NULL,
valid_to date,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (valid_to IS NULL OR valid_to > valid_from),
EXCLUDE USING gist (
apartment_id WITH =,
daterange(valid_from, COALESCE(valid_to, 'infinity'::date), '[)') WITH &&
)
);
-- ===========================================================================
-- 2. DEVICES & INGEST
--
-- The radio world: gateways listen, meters broadcast, frames arrive.
-- Design stance: wM-Bus at 868 MHz is a BROADCAST medium. A gateway does
-- not "own" the sensors it hears — two gateways in one building may both
-- receive the same meter, and every gateway will receive meters that are
-- not ours (neighbors, unprovisioned devices). The schema therefore:
-- * ties gateways to buildings (where they are installed), never to
-- sensors;
-- * records which gateway heard a frame on the frame itself, because
-- that is a fact about the reception event, not about the meter;
-- * accepts and stores frames from unknown senders instead of
-- dropping them at the door.
-- ===========================================================================
CREATE TABLE gateway (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- IMEI identifies the cellular modem and is unique per device, but it
-- is a hardware serial, not an identity: a defective gateway swapped
-- in the field is, to the business, "the same listening post" with a
-- new IMEI. A surrogate PK keeps history intact across swaps; the
-- unique index still supports lookup-by-IMEI at ingest time.
imei varchar(15) NOT NULL UNIQUE,
imsi varchar(15), -- SIM identity; changes with
-- provider swaps, so informative
-- only, never a key
building_id uuid NOT NULL REFERENCES building(id),
label text, -- short admin handle, "GW Keller"
-- Free-text installer note answering "where exactly is this thing?":
-- e.g. "mounted above the first ceiling panel on level 1". Kept per
-- device (not on a shared location entity) so that editing one
-- device's note can never silently rewrite another's.
installation_note text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- A device profile: one row per (manufacturer, model) of meter we know
-- how to decode. Exists so that parsing knowledge attaches to the KIND
-- of device rather than to each physical unit — provisioning meter #500
-- of a known model requires zero new parser configuration. This is the
-- schema-level expression of the product strategy "works with any open
-- OMS sensor": supporting a new vendor means inserting rows here and in
-- value_parser, not shipping code.
CREATE TABLE device_type (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
manufacturer text NOT NULL, -- OMS M-field, e.g. 'QDS'
model text NOT NULL,
medium text, -- OMS medium byte, informative
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (manufacturer, model)
);
-- Extraction rules: how to pull ONE value out of a decoded telegram of a
-- given device type. A telegram routinely carries several values we care
-- about (energy total, volume, temperatures, error register), hence
-- 1 device_type : N parsers. Each parser declares:
-- * where the value sits (json_path into the decoded telegram),
-- * how to normalize it (parser_instructions: scaling, offsets),
-- * what it MEANS (kind) and in what unit it is expressed.
-- The kind declared here is what stamps every measurement row with its
-- heating/hot-water semantics — the parser is the single place where
-- raw vendor bytes acquire domain meaning.
--
-- unit is plain text ('kWh', 'm3', '°C') rather than a lookup table:
-- a three-row table adds a join and an id without adding information.
-- Reintroduce a unit table only if units ever need behavior or metadata
-- (conversion factors, display localization).
CREATE TABLE value_parser (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_type_id uuid NOT NULL REFERENCES device_type(id),
json_path text NOT NULL,
parser_instructions jsonb,
kind measurement_kind NOT NULL,
unit text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (device_type_id, kind, json_path)
);
CREATE TABLE sensor (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- The meter's own identity as broadcast in its telegrams (OMS
-- secondary address / serial). This is the value ingest matches on
-- to attribute an incoming frame to a known sensor.
oms_id text NOT NULL UNIQUE,
device_type_id uuid NOT NULL REFERENCES device_type(id),
-- The device-to-apartment assignment (UVI-FUN-02). Direction matters:
-- an apartment has MANY devices (a heat cost allocator per radiator
-- plus a hot-water meter is the normal case), a device sits in at
-- most one apartment. NULLable by design — in the pilot this
-- assignment is performed manually in the database after physical
-- installation, so a sensor legitimately exists in a provisioned-
-- but-unassigned state. Its measurements are stored regardless and
-- become tenant-visible once the assignment is made.
apartment_id uuid REFERENCES apartment(id),
-- AES-128 key for OMS telegram decryption. Plaintext here is a
-- pilot-grade decision to keep moving; before production, move key
-- material to a dedicated mechanism (pgsodium / Vault) — the column
-- is the interface, the storage hardening is an ops task.
decrypt_key text,
installed_at date,
-- Same rationale as gateway.installation_note: "which radiator,
-- which room, how to reach it" is per-device installer knowledge.
installation_note text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Note what sensor deliberately does NOT have: a gateway reference (the
-- radio is broadcast; reception routing lives on received_payload) and a
-- location/building reference (derived via apartment -> building; a
-- second, independent path would allow the two to contradict each other).
-- Raw reception log: every frame any of our gateways heard, verbatim.
-- This table exists for auditability and reprocessing — if a parser had
-- a bug, or a new device_type is added for meters we were already
-- hearing, history can be re-parsed from here. Storage is the price of
-- never losing data to a software mistake.
CREATE TABLE received_payload (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
gateway_id uuid NOT NULL REFERENCES gateway(id),
-- Attribution to a known sensor happens at parse time and is
-- NULLABLE: on a shared radio band we routinely receive frames from
-- meters that are not ours. Those are stored (they may become ours
-- — e.g. a meter provisioned after installation) and flagged via
-- parse_status rather than rejected at ingest.
sensor_id uuid REFERENCES sensor(id),
-- Both ends of the transport hop. sent = gateway clock at upload,
-- received = server clock at arrival; their divergence is the
-- cheapest possible gateway-health signal (clock drift, buffering
-- backlog after connectivity loss).
timestamp_sent timestamptz,
timestamp_received timestamptz NOT NULL DEFAULT now(),
raw_payload jsonb NOT NULL,
-- Ingest pipeline state machine. 'unknown_sensor' is a normal,
-- expected state, not an error — see attribution note above.
parse_status text NOT NULL DEFAULT 'pending'
CHECK (parse_status IN ('pending','parsed','unknown_sensor','error')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON received_payload (sensor_id, timestamp_received DESC);
-- Partial index: the parser worker's queue query ("give me unprocessed
-- frames") stays fast no matter how large the historical log grows.
CREATE INDEX ON received_payload (parse_status)
WHERE parse_status IN ('pending','error');
-- Raw-frame retention is an open decision. When made, implement it as a
-- scheduled purge (or convert this table to a hypertable and attach a
-- Timescale retention policy) — do NOT cascade-delete from measurement;
-- measurement.raw_payload_id is nullable precisely so lineage can be
-- severed by retention without touching the measurements themselves.
-- ===========================================================================
-- 3. MEASUREMENT (time series)
--
-- The product's ground truth: one row per (meter, kind, instant) reading.
-- Everything the UVI shows is computed from these rows at query time —
-- no persisted aggregates (UVI-DAT-06). That rule keeps a single source
-- of truth and makes bug fixes retroactive for free: correct a parser,
-- re-parse, and every dashboard number is correct.
-- ===========================================================================
CREATE TABLE measurement (
sensor_id uuid NOT NULL REFERENCES sensor(id),
-- The METER's own reading time, extracted from the telegram — not
-- the reception time. wM-Bus meters repeat frames and gateways
-- buffer during connectivity loss, so arrival time can trail reading
-- time by minutes to days; all consumption math (monthly windows,
-- year-over-year) must bind to when the meter measured.
measured_at timestamptz NOT NULL,
kind measurement_kind NOT NULL,
-- NOT NULL: a reading without a value is not a reading. Missing data
-- is represented by the ABSENCE of rows for a period, and the UI
-- renders that as "not available" (UVI-FUN-15) — never by storing 0
-- or NULL, both of which would poison aggregations silently.
value numeric NOT NULL,
-- Denormalized from value_parser at write time so a row is
-- self-describing even if parser configuration later changes.
unit text NOT NULL,
-- Lineage to the exact raw frame this value was parsed from.
-- Nullable so raw-frame retention can delete old frames without
-- invalidating measurements (see received_payload).
raw_payload_id uuid REFERENCES received_payload(id),
created_at timestamptz NOT NULL DEFAULT now(),
-- Composite natural key, doing three jobs at once:
-- 1. TimescaleDB requires the partitioning column in any unique
-- constraint — a bare uuid PK is incompatible with a hypertable.
-- 2. Dedup: meters broadcast the same reading repeatedly by design.
-- Ingest uses INSERT .. ON CONFLICT DO NOTHING against this key,
-- so repeats collapse to no-ops instead of duplicate rows that
-- would double-count consumption.
-- 3. It IS the dominant access path (this sensor, this kind, this
-- time range), so the PK index serves the dashboard queries and
-- no secondary index is needed for the pilot.
PRIMARY KEY (sensor_id, kind, measured_at)
);
-- Monthly chunks match the product's natural query grain (the UVI is a
-- monthly report over a 13-month window).
SELECT create_hypertable('measurement', by_range('measured_at', INTERVAL '1 month'));
-- If the 13-month dashboard aggregations ever get slow at scale, the
-- sanctioned escape hatch is a Timescale continuous aggregate: it is
-- machine-maintained derived state over this table, which arguably
-- honors the intent of "no persisted aggregates" (no hand-maintained
-- second truth). Adopt it when measured, not preemptively.
-- ===========================================================================
-- 4. REFERENCE DATA
--
-- External regulatory and environmental facts the UVI computes against
-- (UVI-DAT-07..11). Two shared design rules:
--
-- * Versioning by validity range wherever the source can change
-- (CO2 factors, prices): the question "what number was shown to the
-- tenant in March?" must remain answerable after an update, both for
-- tenant trust and for the § 7 Kürzungsrecht audit scenario.
-- * source columns record provenance, because every one of these
-- numbers is a claim about a legal document or an external dataset.
-- ===========================================================================
-- Bandtacho class boundaries (Module 3). GEG Anlage 10 defines ANNUAL
-- kWh/m² class limits; the UBA-Leitfaden (Tabelle 1) splits them into
-- per-calendar-month thresholds so a single month's consumption can be
-- classified. Hence one row per (class, month): 9 classes × 12 months.
CREATE TABLE efficiency_class_threshold (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
class text NOT NULL CHECK (class IN
('A+','A','B','C','D','E','F','G','H')),
month smallint NOT NULL CHECK (month BETWEEN 1 AND 12),
-- Upper bound of the class for that month. 'H' is open-ended in the
-- source; store a sentinel high value rather than NULL so that
-- classification is a simple "first class whose bound >= value"
-- scan with no NULL special-casing.
max_kwh_per_m2 numeric(8,3) NOT NULL,
source text NOT NULL DEFAULT 'UBA CC 69/2021 Tab. 1 / GEG Anl. 10',
UNIQUE (class, month)
);
-- CO2 factors per fuel (Module 6), GEG Anlage 9. Versioned because the
-- annex gets amended; emissions shown for a given month use the factor
-- valid in that month.
CREATE TABLE co2_emission_factor (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
fuel_type fuel_type NOT NULL,
g_co2_per_kwh numeric(8,2) NOT NULL,
valid_from date NOT NULL,
valid_to date, -- NULL = currently valid
source text NOT NULL DEFAULT 'GEG Anlage 9',
UNIQUE (fuel_type, valid_from)
);
-- Energy prices per fuel (Module 5: cost estimate = kWh × price).
-- Versioned for the same auditability reason, plus created_by: price
-- maintenance is a manual admin process (owner and cadence still an
-- open decision), and attributing each price row makes that process
-- accountable from day one regardless of how the decision lands.
CREATE TABLE energy_price (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
fuel_type fuel_type NOT NULL,
ct_per_kwh numeric(8,3) NOT NULL,
valid_from date NOT NULL,
valid_to date,
source text,
created_by uuid REFERENCES app_user(id),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (fuel_type, valid_from)
);
-- DWD stations we import heating-degree-day data for. Modeled as its own
-- entity (not columns on building) because many buildings share a
-- station and the HDD import job iterates stations, not buildings.
CREATE TABLE weather_station (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
dwd_id text NOT NULL UNIQUE, -- DWD's station identifier
name text NOT NULL,
lat numeric(9,6),
lng numeric(9,6),
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE building
ADD CONSTRAINT building_weather_station_fk
FOREIGN KEY (weather_station_id) REFERENCES weather_station(id);
-- Monthly heating degree days per station (VDI 3807, DWD open data).
-- Consumed ONLY as display context — the Module 1 footnote "this month
-- was N% colder/warmer than the same month last year". Actual weather
-- normalization of consumption values is explicitly out of scope for
-- the UVI (it belongs to annual billing) and measurement values are
-- never adjusted by this data.
-- Operational note: a monthly DWD import fills this table; if a month
-- is missing, the footnote renders "not available" (UVI-FUN-15). The
-- import's timing sits inside the NFR-05 chain (data visible by the
-- 5th working day).
CREATE TABLE heating_degree_days (
station_id uuid NOT NULL REFERENCES weather_station(id),
year smallint NOT NULL,
month smallint NOT NULL CHECK (month BETWEEN 1 AND 12),
hdd numeric(7,1) NOT NULL,
PRIMARY KEY (station_id, year, month)
);
-- Savings tips library (Module 4, UVI-FUN-11). A table, not content in
-- code, because tips are editorial material admins curate. The category
-- drives seasonal selection (UVI-FUN-14): heating tips during the
-- heating period, hot-water tips outside it. active supports retiring
-- tips without deleting them (a tip already shown in a past month
-- should remain resolvable).
CREATE TABLE saving_tip (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
category saving_tip_category NOT NULL,
title text NOT NULL,
body text NOT NULL,
link_url text, -- deep link to advisory content
-- (verbraucherzentrale, co2online)
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
-- ===========================================================================
-- 5. TENANT ISOLATION (UVI-NFR-03) — enforcement sketch
--
-- Isolation is enforced IN the database via row-level security, not only
-- in the Go backend: with Supabase Auth issuing JWTs, auth.uid() is
-- available inside policies, and a backend bug can then never leak a
-- neighbor's data — the worst case becomes an empty result.
--
-- Example policy shape for measurement (enable analogously per table):
--
-- ALTER TABLE measurement ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY tenant_reads_own ON measurement FOR SELECT
-- USING (
-- sensor_id IN (
-- SELECT s.id
-- FROM sensor s
-- JOIN occupancy o ON o.apartment_id = s.apartment_id
-- WHERE o.user_id = auth.uid()
-- AND daterange(o.valid_from,
-- COALESCE(o.valid_to, 'infinity'::date),
-- '[)') @> measured_at::date
-- )
-- );
--
-- The occupancy-window predicate is the important part: a tenant sees
-- only measurements from their own tenancy period, so pre-move-in
-- history is structurally invisible (the Mieterwechsel rule enforced at
-- the storage layer). Admins get a separate permissive policy scoped to
-- role = 'admin'; admin reads are additionally logged at the application
-- layer (UVI-NFR-07).
--
-- Deliberately NOT in the schema:
-- * The small-building anonymity rule (UVI-FUN-09: with ≤ 4 units,
-- show the mean of the three thriftiest households instead of the
-- spectrum) — that is presentation logic in the in-house comparison
-- query, driven by count(apartment) per building at query time.
-- * Heating-period vs. off-season behavior (UVI-FUN-14) — pure display
-- logic; the data model is season-agnostic.
-- ===========================================================================