> ## Documentation Index
> Fetch the complete documentation index at: https://docs.energy.nlead.ch/llms.txt
> Use this file to discover all available pages before exploring further.

# Operator App

> The read-only /v1/mobile API behind the iPhone, Android and Apple Watch app, its device keys and its contract

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.

<Warning>
  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.
</Warning>

## 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:

```bash theme={null}
MOBILE_API_KEY_HASHES="mobile-<handle>-phone:<sha256 hex>,mobile-<handle>-watch:<sha256 hex>"
```

* 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.

| Variable | Default | Meaning |
| - | - | - |
| `MOBILE_API_KEY_HASHES` | unset | Device keys as hashes, see above |
| `MOBILE_RATE_LIMIT_PER_MINUTE` / `MOBILE_RATE_LIMIT_BURST` | `30` / `10` | Token bucket per device, separate from every other caller's |
| `MOBILE_MIN_APP_VERSION` | `0.1.0` | Apps below this version show "Bitte aktualisieren" instead of data |
| `MOBILE_STALE_AFTER_SECONDS` / `MOBILE_EXPIRE_AFTER_SECONDS` | `1800` / `21600` | When the apps add "Stand HH:MM", and when they turn grey |
| `MOBILE_HEARTBEAT_STALE_AFTER_SECONDS` | `1200` | When the orchestrator's timer counts as stopped. Raise it with a slower `orchestrateCron` |

## Endpoints

| Endpoint | What it returns |
| - | - |
| `GET /v1/mobile/summary` | Service state, LIVE/TEST, the orchestrator's heartbeat, the day ahead (T+1: source, the three steps, Engrate's verdict, the gate), the [badge](#the-day-ahead-badge) and its timeline, the day-ahead milestones, every day from yesterday to the end of the forecast horizon, the feed's warnings and errors of the last 24 hours (`events_24h`), and the `snapshot` for widgets and the watch. Shared between devices for 15 seconds |
| `GET /v1/mobile/events` | The [event feed](#the-event-feed): warnings, errors and the day's routine steps, in fixed words, paged by cursor |
| `GET /v1/mobile/forecasts` | The stored [forecast qualities](#forecasts) of up to 14 days; quarter-hour series for one day |
| `GET /v1/mobile/market-partners` | [Market communication](#market-partners): status counts per control area and every standing rejection. Shared for 5 minutes |
| `GET /v1/mobile/kundenhochlauf` | [Cloover's confirmed customers](#kundenhochlauf) against the market, urgent areas first. Shared for 5 minutes |

More endpoints are added as the app grows; the
[contract](#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:

| Query | Page |
| - | - |
| none | The newest page. `has_more` means older events exist |
| `before_id=N` | Older than `N`. Continue with `next_before_id` |
| `after_id=N` | Newer than `N`. Continue with `next_after_id` |
| `level=warning` / `error` | The lowest level shown, combined with any of the above |

* 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:

| `stream` | Rejection | `reason_code` |
| - | - | - |
| `partin` / `zuordnung` | a negative APERAK or CONTRL to our PARTIN / Zuordnungsermächtigung | `Z29` from `APERAK Z29…`, `23` from `CONTRL abgelehnt (Syntaxfehler 23…`, else null |
| `transport` | an AS4 delivery that failed and was not followed by a successful one | null |

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:

| # | When | Colour | `code` | Label |
| - | - | - | - | - |
| 1 | no input, before closure | amber | `no_input` | keine Daten |
| 2 | no input, closure passed | red | `no_input_gate_passed` | keine Daten |
| 3 | the submission was rejected | red | `rejected` | abgelehnt |
| 4 | accepted, balanced | green | `accepted` | angenommen |
| 5 | accepted with imbalances | amber | `imbalance` | Ungleichgewicht |
| 6 | a newer input not yet submitted, an older one accepted, closure passed | amber | `old_version_accepted` | ältere Version angenommen |
| 7 | closure passed | red | `gate_missed` | Gate verpasst |
| 8 | inside the warning window | amber | `gate_warning` | Gate schließt (with `countdown_to`) |
| 9 | only the backup, before 05:00 | neutral | `awaiting_forecast` | Prognose erwartet 05:00 |
| 10 | only the backup | amber | `backup_only` | nur Backup |
| 11 | a newer input not yet submitted | amber | `new_forecast` | neue Prognose |
| 12a | not published | amber | `not_published` | nicht veröffentlicht |
| 12b | published after the trader's 09:00 pickup | amber | `published_late` | nach Abholung veröffentlicht |
| 13 | otherwise | neutral | `running` | läuft |

* 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.

| Status | `error` |
| - | - |
| 401 | `unauthorized` — no key, or an unknown one |
| 403 | `forbidden` — a valid key of another scope |
| 404, 405, 413, 422 | `invalid_request` |
| 429 | `rate_limited` — wait the seconds in `Retry-After` |
| 502, 503, 504 | `upstream_unavailable` |
| 500 | `internal` |

## 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.

<Steps>
  <Step title="Generate the key and its hash">
    On your own machine, without echoing the key:

    ```bash theme={null}
    KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')
    HASH=$(printf %s "$KEY" | sha256sum | cut -d' ' -f1)
    ```

    `token_urlsafe` never produces `,` or `:`, which the list uses as
    separators.
  </Step>

  <Step title="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:

    ```bash theme={null}
    printf %s "$VALUE" | python3 scripts/validate_mobile_key_hashes.py
    ```

    It prints the device names, or why the service would refuse to start.
  </Step>

  <Step title="Store it in Key Vault">
    ```bash theme={null}
    az keyvault secret set --vault-name "$VAULT" \
      --name mobile-api-key-hashes --value "$VALUE" --output none
    ```

    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"`.
  </Step>

  <Step title="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:

    ```bash theme={null}
    az containerapp secret set -n "$APP" -g "$RG" \
      --secrets "mobile-api-key-hashes=keyvaultref:https://$VAULT.vault.azure.net/secrets/mobile-api-key-hashes,identityref:$IDENTITY_ID"
    az containerapp update -n "$APP" -g "$RG" \
      --set-env-vars MOBILE_API_KEY_HASHES=secretref:mobile-api-key-hashes
    ```

    The `update` creates a new revision and **restarts production**.
  </Step>

  <Step title="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:

    ```bash theme={null}
    az containerapp revision restart -n "$APP" -g "$RG" \
      --revision "$(az containerapp show -n "$APP" -g "$RG" --query properties.latestRevisionName -o tsv)"
    ```

    Then check *Status → security → mobile\_callers* in the console: the
    list is the new one.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.