Skip to main content
The operator app shows the state of the day-ahead pipeline on a phone and a watch. It reads /v1/mobile and nothing else. Every endpoint is a GET, and it answers only to a device key. That key opens nothing outside /v1/mobile, and no other key opens /v1/mobile — not even the process key.
The app sends nothing: no orchestrator run, no schedule, no EDIFACT, no mail, no configuration change. Actions come later, each with a server-side preview, an explicit confirmation on the device and a server-side audit entry.

Device keys

Each device gets its own named key: one per phone, one per watch. The service never holds a key. It is configured with the key’s SHA-256 hash:
  • Names must match mobile-[a-z0-9-]+. Use a pseudonymous handle, not a person’s name. The name is what the console and the event log show.
  • Every device has its own key. Two entries with the same hash are refused, and so is a hash of any other configured secret, including one set from the console. The console in turn refuses to set a secret to a device key.
  • Device keys count as configured credentials. With only device keys set, every other route answers 401, and ALLOW_UNAUTHENTICATED has no effect.
  • A malformed value stops startup, like the other key lists. Check the complete value with scripts/validate_mobile_key_hashes.py before setting it (below).
  • Adding or revoking one device needs nobody else’s key, because only hashes are stored.
  • The console’s configuration view masks the value. Status → security → mobile_callers lists every configured device with its last request, request count, app version and platform. That is kept in memory only, and no IP address is ever recorded.
  • The first request of each device after a start logs mobile_key_first_use. Reads are otherwise not logged, and no mobile_* event reaches the Slack channel shared with Cloover.

Endpoints

More endpoints are added as the app grows; the contract lists what exists.

The event feed

The feed shows every cataloged warning and error, plus the routine steps an operator follows through the day: forecast received, backup created, CSV published, schedules submitted, service started. It never shows the access log, the mail log, the classifier’s second opinions or the app’s own mobile_* events. No message, no data. An event’s message and data can carry MaLo IDs, e-mail addresses, client addresses and partners’ free text. None of it reaches the device. The words (text_de, text_en) are built on the server from the event’s type, its delivery day and typed numbers and flags only, for example “30.07.2026: Nicht eingereicht vor Gate-Schluss, noch 25 Min.”. As a second line of defence they are redacted once more for e-mail addresses, IDs, IBANs, phone numbers, URLs and IP addresses. Paging never skips or repeats an event:
  • Pages are oldest first. limit is 1–200 (default 50).
  • Both cursors at once are an invalid_request.
  • A page reads a bounded number of events, so with many events the feed leaves out, a page can come back short or empty while has_more is true. The cursors still move past everything read: keep paging.
  • An app polls with after_id set to the last page’s next_after_id.
  • epoch is the first event’s timestamp, or when the log was found truncated, restored or replaced. Ids never repeat; when the epoch changes, start again from the newest page, because the events before the cursor are no longer the ones the app saw.
events_24h in the summary and the snapshot counts the warnings and errors of the last 24 hours that the feed would show.

Forecasts

?von&bis names at most 14 days (default: today to the end of the forecast horizon). Every day of the range is listed, a day without a forecast with no versions. Per day: the active quality, the three steps, the confirmed customers in supply per control area, and each stored version (customer, generated, backup) with its energy per control area. A backup also carries its derivation (copied_from, method, customers_without_reference), and its deviation from the customer’s forecast once both exist. series=true works for a single day only. It adds each version’s quarter-hour totals in MW and interval_starts_local, the label of every quarter-hour. On the autumn clock change the repeated hour reads 02:00 A … 02:45 A, 02:00 B … 02:45 B, the way EPEX marks it; in spring 02:00–02:45 does not exist. A forecast’s MaLo mapping never leaves the service, and there is no suppression of small portfolios.

Market partners

The partner overview reduced to counts: totals counts every registry partner once; areas[] counts each partner once in every control area it has Bilanzierungsgebiete in. rejected[] lists every standing rejection, newest first, one entry per stream: The labels (reason_label_de, reason_label_en) are fixed words, for example “APERAK Z29: Absender nicht registriert”, with the fallbacks “APERAK negativ”, “CONTRL negativ” and “Übertragungsfehler (AS4)”. The partner’s own text never reaches the app: not an APERAK’s FTX, not a transport error, not an operator’s note. Partner names and MP-IDs are public BDEW registry data. The app shows them on this screen, never in a widget, on the watch or on the lock screen. When MakoFlow cannot be read, the answer is 503 upstream_unavailable, not old data.

Kundenhochlauf

Per Bilanzierungsgebiet with confirmed customers: the Netzbetreiber (its registry name, never the name in Cloover’s sheet), its market communication status, the Zeitreihentypen not active in time (fehlend), ohne_zoe and customers without a forecast type (ohne_typ). An area is urgent when something is missing and its first supply start is at most 14 days away, or past. Urgent areas come first.
  • kein_quellstand: Cloover has sent no snapshot yet.
  • unvollstaendig: the comparison with the market could not run (MakoFlow unreachable). The areas are then missing, not fine. Why it failed is not passed on.
  • In either case the app says so; it never says “nothing urgent”.
  • stand is the sheet’s own date, YYYY-MM-DD HH:MM; the rest of that note stays on the server.
  • Reading it records nothing: unlike the console’s view, the app’s does not store newly seen activations.

The day-ahead badge

One colour, code and label says how the day ahead stands. The server decides it with one function; the apps render what they are given and hold no rules or label tables of their own. Labels come in German and English (label_de, label_en). The first matching rule wins:
  • A stopped service turns a neutral or green badge into amber stopped (“gestoppt”); amber and red keep their own.
  • Every badge carries next_gate_at once today’s gate has passed.
  • Closure and warning come from the live configuration (GATE_CLOSURE_LOCAL_TIME, GATE_CLOSURE_WARN_MINUTES). 05:00, 09:00 and 12:00 are fixed.
  • t_plus_1.published_at is when the current input’s CSV first went out; sending the same file again does not move it. Days published before it was recorded count as in time.
  • last_orchestrate_at comes from service_heartbeat.json, which every orchestrator call writes, also while stopped. heartbeat_stale is set after 20 minutes without a call (MOBILE_HEARTBEAT_STALE_AFTER_SECONDS).
  • The summary is shared between devices for at most 15 seconds, and never across an event: a configuration change, a stop or start, a submission, a publish or a new forecast makes the next request read afresh.
badge_timeline says what the badge will be at future instants if nothing changes and nothing is fetched. Widgets and complications use it to change colour offline. It is built with the same function, at the instants where a rule can change: now, today’s 05:00, 09:00, warning start and closure, the next midnight (from which the next day ahead is read), and the same four instants tomorrow. It ends at timeline_end, the midnight after that. Staleness is the one rule the apps apply themselves, with the server’s thresholds. From generated_at + stale_after_s (30 minutes) they keep the timeline’s colour and add “Stand HH:MM”. From generated_at + expire_after_s (6 hours) or timeline_end, whichever comes first, they show grey. Grey never comes from the server. snapshot is exactly what native surfaces may store: the badge, its timeline, overlays, the day ahead’s steps and gate, and counts (including events_24h). It holds no energy, names, event text or caller. Widgets, the watch and the Live Activity store it verbatim, so the contract covers them too. Time. Every instant is RFC 3339 with seconds and an explicit offset; event timestamps are UTC (Z). Every Berlin wall-clock value a person reads also comes as a *_local "HH:MM" string, so the app needs no time-zone data. Today is today in Berlin. The day ahead has 92, 96 or 100 quarter-hours. Errors are exactly {"error": "<code>"}, with no detail text. Every response carries Cache-Control: no-store, errors included.

The contract

mobile/contract/ in the repository is the contract between the service and the apps:
  • openapi.json — only the /v1/mobile paths and their schemas, generated by python scripts/generate_openapi.py from the same response models the service uses. Every model forbids extra fields, so the service returns exactly what is declared.
  • fixtures/<endpoint>/<scenario>.json — canonical responses on a fixed clock: for the summary, one per badge code plus a stale heartbeat, a backup replaced by the forecast and both clock changes; for the event feed, the newest page, paging back and forward, caught up, and errors only; for forecasts, a range and two days with series (one of them the autumn clock change); for market partners, rejections of every kind and no traffic; for the Kundenhochlauf, urgent areas, no snapshot and an incomplete comparison. They are regenerated with UPDATE_MOBILE_FIXTURES=1 pytest tests/test_mobile_contract.py.
The apps decode these same fixtures in their own tests. A backend change that breaks a client therefore fails in the same pull request. CI fails when the spec or a fixture is stale. On pull requests it also runs oasdiff breaking against the base branch’s spec. Within v1, changes are additive only: new fields and new enum values. Clients ignore fields they do not know and map unknown enum values to “unknown”. A removed or retyped field goes to /v2/mobile. Remove a v1 field only after mobile_callers has shown no app version that still reads it for 30 days.

Runbook: adding, rotating or revoking a device key

The operator runs these steps; nothing here runs in CI or from Claude. Never paste a key into a chat, a ticket or a terminal that is being shared. Restarts avoid 05:00 and 13:30–14:30 Berlin time.
1

Generate the key and its hash

On your own machine, without echoing the key:
token_urlsafe never produces , or :, which the list uses as separators.
2

Build and check the complete value

The value is the whole list. Take the current list from the password manager, add mobile-<handle>-<device>:$HASH (or remove the revoked entry), then check it:
It prints the device names, or why the service would refuse to start.
3

Store it in Key Vault

Key Vault keeps every version, so a bad value can be rolled back. Without Key Vault, use a Container Apps secret instead: az containerapp secret set -n "$APP" -g "$RG" --secrets mobile-api-key-hashes="$VALUE".
4

First time only: reference it from the app

The container app needs an identity that may read the vault (Key Vault Secrets User on the vault). Then reference the secret and map it to the variable:
The update creates a new revision and restarts production.
5

Later changes: restart the running revision

A new secret value alone changes nothing in the running revision. After adding a device, rotating or revoking a key, restart the latest revision:
Then check Status → security → mobile_callers in the console: the list is the new one.
6

Hand the key to the device

Through the password manager, which fills the app’s key field without the clipboard. A QR code is fine if it is generated offline (printf %s "$KEY" | qrencode -t ANSIUTF8): never with a web generator, never during a screen share. Clear the terminal and unset KEY afterwards.
Neither deploy.yml nor the bicep template touches these settings. Never re-run the full template against production: it drops the secrets and environment variables set this way.