> ## 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: Actions and Push (Design)

> Phase 2 (actions from the phone, signed on the device, with four-eyes approval) and phase 3 (native push) - a design, not approved and not implemented

<Warning>
  **Design only.** Nothing on this page is implemented, and none of it
  starts without explicit approval - PR by PR, in the order at the end.
  Today the app reads `/v1/mobile` and sends nothing ([Operator
  App](/setup/mobile)).
</Warning>

Decided so far:

* Phase 2 uses **hardware-backed device keys**, not OIDC.
* It has **four-eyes approval**. Which actions need it is proposed in the
  table below and not yet settled; the APERAK acknowledgement is the open
  case.
* Native code talks to Flutter through the app's **own channels**, with no
  plugins.
* **The watch stays read-only.** So do the widgets and the Live Activity.

Still open is the [list at the end](#open-decisions), among them Android
push (FCM or Pushover) and whether Wiedervorlagen may be closed on the
phone.

## What stays as it is

* The read router stays `GET`-only. Today's test asserts that for every
  path under `/v1/mobile`. It will exclude exactly two things:
  * `/v1/mobile/actions/*`, which gets its own allowlist test;
  * the push-token route of phase 3.
* **On its own, the read key sends nothing and changes nothing in the
  pipeline.** It can read, ask for previews, enrol a signing key that then
  waits for approval in the console, and, in phase 3, register a push
  token.
* **No bearer secret alone can do anything.** Every action needs a fresh
  signature from a key that never leaves the phone's secure hardware, and
  each signature needs the operator's biometrics.
* The server makes every plan; the phone shows it and signs it. The phone
  never builds a target list or a message that goes out.
* The one exception is the Notiz, whose text the operator types. The
  preview shows that text exactly as Cloover will read it. The `person`
  is set by the server from the handle, never by the phone.
* Nothing is sent automatically and nothing without a preview. There is no
  "execute without preview", neither in the app nor on the server: execute
  accepts nothing but a preview id.
* Mobile payloads carry no MaLo or MeLo, no partner contact data and no
  partner free text, also in previews.

## Phase 2: actions

### Which actions

The candidates come from the brief. Each wraps an existing service
function; four of them already have a `dry_run` the preview can use.

| Action | Wraps | Dry run today | Sends | Proposal |
| - | - | - | - | - |
| Start the service | `ConfigManager.set_running(True)` (`POST /v1/console/start`) | no; the preview states the effect | nothing itself | one person |
| Stop the service | `ConfigManager.set_running(False)` (`POST /v1/console/stop`) | no | the cycle stops; `service_stopped` goes to the Cloover feed and raises a NORMAL alert, which must name the phone, not "the console" | one person; **refused from `warn_from` to the gate closure while T+1 is not final** |
| Protokoll-Notiz | `notiz_erfassen` (`POST /v1/process/protokoll/notiz`) | no; the preview renders the Slack text with `_slack_zeilen` | Slack to Cloover, with the next Protokoll run (every `slack_protokoll_intervall_minuten`, 60 by default) and only while the service runs | one person |
| Acknowledge received messages | `acknowledge_received` (`POST /v1/process/aperak/acknowledge`) | yes | positive APERAKs to market partners | one person (deadline-bound: 45 min for UTILMD) |
| Answer incoming PARTINs | `answer_partins` (`POST /v1/process/partin/answer-incoming`) | yes | PARTIN 37003 to market partners | **four eyes** |
| Resend rejected PARTINs | `resend_rejected` (`POST /v1/process/partin/resend-rejected`) | yes | PARTIN to market partners | **four eyes** |
| Auto-Zuordnung, default flags | `auto_zuordnung` (`POST /v1/process/zuordnungsermaechtigungen/auto`) | yes | UTILMD Zuordnungsermächtigungen to DSOs | **four eyes** |
| Close a Wiedervorlage | `wiedervorlage_erledigt` | no | `wiedervorlage_erledigt`, with the key, to the Cloover feed; no mail moved | open - see below |

**Never on the phone:**

* `orchestrate` and the manual pipeline steps;
* `PUT /v1/console/config`, mailbox processing, all `mail/*`;
* single or custom Zuordnungen, the PARTIN fan-out;
* Korrektur, Nachfass and Nachversand sends, override flags;
* imports, partner-book edits, and the external and `quellstand` writes.

<Accordion title="Stopping the service in the gate window" icon="hand">
  Stopping sends nothing, but it stops the cycle that submits T+1 before
  the gate closes. So the server refuses it from `warn_from` (closure minus
  `gate_closure_warn_minutes`) to the closure while T+1 is not final.

  The backend has no "final" yet. The proposal: T+1 is final when its
  badge code is `accepted` or `imbalance`, meaning the current version was
  submitted and accepted, balanced or not. (`old_version_accepted` occurs
  only after closure, so it does not matter here.)

  The console's own stop stays as it is.
</Accordion>

<Accordion title="Wiedervorlagen" icon="inbox">
  A Wiedervorlage is keyed by the sender's e-mail address, by an MP-ID, or
  by a Message-ID. None of these may reach the phone, so closing one there
  needs opaque, server-side ids that map back to the key.

  Without the mail's address and text, the phone also has too little to
  decide on. The proposal: **not in phase 2**. If you want it later, it
  closes without moving the mail, because IMAP is mailbox processing.
</Accordion>

### Identity: a signing key per phone

Each phone gets a second key, next to its read key: a **P-256 signing key
that cannot leave the secure hardware and needs biometrics for every
signature**. Native code reaches it through a new `bridge/signing` channel
(`enrol`, `sign`, `delete`), like the existing `bridge/key`.

<Tabs>
  <Tab title="iOS">
    * The key lives in the Secure Enclave, created with
      `.privateKeyUsage` and `.biometryCurrentSet`. A new fingerprint or
      face enrolled on the phone invalidates it.
    * iOS cannot attest such a key. The enrolment check is therefore
      human: the console and the app show the key's fingerprint, and
      the operator compares them. A re-signed copy of the app would show
      its own key's fingerprint and pass that check. This is an accepted
      residual risk: the app comes only through the operator's own
      distribution, and each phone is approved by someone who holds it.
    * App Attest is not proposed. It proves a genuine app build, not the
      biometric key, and adds Apple's attestation service.
  </Tab>

  <Tab title="Android">
    * The key is an Android Keystore EC key created with:
      * `setIsStrongBoxBacked(true)`, falling back to the TEE;
      * `setUserAuthenticationRequired(true)`;
      * `setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG)`;
      * `setInvalidatedByBiometricEnrollment(true)`;
      * `setUnlockedDeviceRequired(true)`;
      * `setAttestationChallenge(<server nonce>)`.
    * Every signature goes through `BiometricPrompt` with a `CryptoObject`.
      `MainActivity` is already a `FlutterFragmentActivity` for this.
    * At enrolment the server verifies the attestation chain:

      * against Google's attestation roots and revocation list;
      * the challenge, the package name and the signing-certificate digest;
      * TEE or StrongBox as the security level, for the key and for the
        attestation;
      * the root of trust: `deviceLocked` true and `verifiedBootState`
        `VERIFIED`;
      * `origin` `GENERATED`, purpose `SIGN`, EC P-256;
      * biometric-only user authentication, with no device-credential
        fallback, auth timeout 0, and `unlockedDeviceRequired`.

      The fingerprint comparison stays as the human check. No Play
      Integrity.
  </Tab>
</Tabs>

**The watch never gets a signing key.** The device registry records the
kind of each device (phone or watch), set by the operator in the console,
and enrolment is refused for anything but a phone.

**Wiping** the app or deleting the key on the phone deletes the signing key
too.

**A new fingerprint or face** enrolled on the phone invalidates the key on
both platforms. The app then shows "Schlüssel für Aktionen neu
einrichten". Re-enrolment revokes the old public key in the registry, and
the new one waits for approval in the console again.

### Enrolment

```mermaid theme={null}
sequenceDiagram
    participant App as Phone (read key)
    participant API as /v1/mobile/actions
    participant Con as Console (process key)
    App->>API: POST enrolment/challenge
    API-->>App: nonce (10 min, bound to the device name)
    App->>App: create the key (Android: attestation over the nonce)
    App->>API: POST enrolment {public key, signature over the nonce, attestation chain}
    API->>API: verify the signature and (Android) the chain, store as pending
    App-->>App: show the key's fingerprint
    Con->>API: approve the phone, fingerprint confirmed
    API-->>Con: active
```

* **Proof of possession:** on both platforms the enrolment carries a
  signature over the nonce, made with the new key.
* **Only one pending enrolment per device.** A new challenge voids the
  previous one.
* **The fingerprint** is the SHA-256 of the public key's SPKI. Both sides
  show at least its first 128 bits, as eight groups of four hex digits.

The server keeps a device registry, a new ledger `mobile_devices.json` on the
audit share. For each device it holds:

* the name and the person's handle;
* the kind (phone or watch);
* the public key and its fingerprint;
* the attested security level;
* when it was enrolled and approved;
* its state: pending, active or revoked.

**Four-eyes counts people, not devices.** The handle in the registry is set
by the operator in the console, not parsed from the name. Each approval
in the console is audited (`mobile_device_approved`), and the console
lists every active device with its handle. Whoever holds the process key
could still register a second handle for themselves; see the threats
below and [decision 4](#open-decisions).

### Revocation without a restart

Today, revoking a device means removing its hash from
`MOBILE_API_KEY_HASHES` and restarting the revision ([runbook](/setup/mobile)).

Phase 2 adds `POST /v1/console/mobile-devices/{name}/revoke`, in the
process scope, which writes a denylist to the audit share. The denylist
holds the read key's hash and the signing key's fingerprint, not the
device name, so a new key issued later under the same name works. From
the next request on, it:

* blocks the device's **read key**;
* blocks its signing key;
* ends the four-eyes requests it opened;
* deletes its push tokens (phase 3).

The hash can leave Key Vault with the next routine change. This is useful
even before any action exists, for a lost phone, so it is the first PR.

### Preview, confirm, execute

```mermaid theme={null}
sequenceDiagram
    participant App as Phone
    participant API as /v1/mobile/actions
    participant Svc as Service function
    App->>API: POST {action}/preview {parameters}
    API->>Svc: dry_run=True
    Svc-->>API: plan
    API-->>App: preview_id, plan, plan_hash, expires_at (5 min), four_eyes
    App->>App: show the plan, "Senden" - biometric signature
    App->>API: POST {action}/execute {preview_id, idempotency_key, issued_at, signature}
    API->>API: device active, signature, ±60 s
    API->>API: known idempotency key? return its result
    API->>API: preview unused, unexpired, bound to this device
    API->>API: idempotency key stored as pending, take the send lock
    API->>Svc: dry_run=True again
    alt plan hash changed
        API-->>App: 409 plan_changed - nothing sent
    else same plan
        API->>Svc: execute, only to the plan's targets
        API->>API: store the result, emit mobile_action_executed
        API-->>App: result
    end
```

* **Router.** The actions sit on a separate `/v1/mobile/actions/*` router
  holding an explicit allowlist. Each action has its parameter schema, its
  preview, its execute and its policy (one person or four eyes). A test
  fails on any route the allowlist does not name. `GET
  /v1/mobile/actions` lists what this device may do now, and why not
  where it may not (for example a stop in the gate window).
* **Preview.** Where the service function has a dry run, the preview
  runs it with an explicit `dry_run=True`. For start, stop and the Notiz
  it states the effect. Only an active, enrolled phone may ask, with at
  most five open previews per device. A preview returns:

  * the plan in German and English;
  * for the actions that send, the targets as partner names and MP-IDs;
  * a `plan_hash` over the plan's content (targets, message types,
    versions), leaving out time stamps.

  The preview is bound to the device and single-use, expires after
  5 minutes, and is kept in memory, because there is one replica. A
  restart loses it, and the operator previews again.

  For each action, a test proves that its preview sends nothing: sockets
  blocked, AS4, SMTP, sFTP and Slack mocked, and the audit share diffed,
  leaving out the MakoFlow cache. Today one dry run fails that test:
  `acknowledge_received(dry_run=True)` still emits `aperak_needs_review`,
  which reaches the Cloover feed. It gets a `dry_run` guard before the
  action exists.
* **Signature.** The phone signs a domain-separated text, not JSON, so
  nothing needs canonicalising:

  ```text theme={null}
  bridge-action-v1
  <action>
  <preview_id>
  <plan_hash>
  <idempotency_key>
  <device name>
  <issued_at, RFC 3339 UTC>
  ```

  It is ECDSA P-256 with SHA-256. The server accepts `issued_at` within
  ±60 s of its clock.
* **Execute.** Execute takes a preview id, an idempotency key, `issued_at`
  and the signature, and nothing else. It checks, in this order:

  1. the device is active and the signature is valid within ±60 s;
  2. the idempotency key: a known one, bound to this device and preview,
     returns its stored result and sends nothing;
  3. the preview is unused, unexpired and bound to this device.

  It then stores the key as pending, takes the send lock (see below),
  runs the dry run again (`409 plan_changed` if the hash differs), checks
  the target cap, and only then sends.
* **Only to the plan's targets.** The sending functions work out their
  targets afresh from MakoFlow on every call. A partner that turned up
  after the preview would otherwise get a message nobody saw or signed.
  So execute hands the plan's targets to the service function through a
  new `nur=` parameter, and it sends to those and no others. A new target
  waits for the next preview. This parameter is part of step 2c.
* **Idempotency.** Keys are kept for 24 hours in a ledger, so they survive
  a restart. A repeated key returns the stored result and sends nothing.
  A key still pending after a crash answers "unknown - check in the
  console" and never sends a second time. The app retries a failed
  request only when the operator taps it, and with the same key.
* **Target cap.** Each execution has a maximum number of targets
  (proposal: 20, `MOBILE_ACTION_MAX_TARGETS`). A larger plan is refused
  with "bitte in der Konsole".
* **The send lock.** Today nothing serialises the sends: `orchestrate`
  takes no lock, and neither do the `/v1/process` routes that send. The
  lock covers only the three sending loops inside `orchestrate`
  (`answer_partins`, `acknowledge_received`, `auto_zuordnung`), the same
  `/v1/process` routes and every execute. The day steps and the mailbox
  never wait for it, so a slow execute cannot hold up a submission before
  the gate.

  The service runs as one replica with one process, so an in-process lock
  is enough. Execute waits at most 30 seconds, then answers `409 busy`. A
  second replica would need a lease on the share instead.
* **Rate limits.** Actions get their own small budget, separate from
  reads.

### Four-eyes

```mermaid theme={null}
sequenceDiagram
    participant A as Person A
    participant API as Server
    participant B as Person B (other handle)
    A->>API: preview, then execute (signed)
    API-->>A: 202 awaiting approval - nothing sent
    B->>API: GET requests
    B->>API: POST requests/{id}/preview
    API-->>B: fresh dry run - same plan_hash, or the request is void
    B->>API: POST requests/{id}/approve (signed by B)
    API->>API: B's handle differs from A's, lock, dry run again, execute
    API-->>B: result
    API-->>A: result, on A's next refresh
```

* An action that needs four eyes is executed by the **second** signature,
  from a **different handle**. A second phone of the same person does not
  count.
* The approver gets a dry run of their own. If its plan hash differs from
  the requester's, the request is void and has to be started again.
  Execute sends only to the requester's targets, as above.
* A request lives for 30 minutes (proposal) and is kept in a ledger, so
  it survives a restart. Either person may withdraw or reject it, with a
  signature as well.
* **A request executes at most once.** Approve, reject and withdraw are
  serialised per request, and the approval carries an idempotency key of
  its own.
* The approver's signature covers the request id and the decision as well
  as the fields above (`bridge-approval-v1`).
* Which actions need four eyes is server-side policy, set by environment
  (`MOBILE_FOUR_EYES_ACTIONS`) and not changeable through the console's
  configuration. The app only shows it.
* It needs **at least two enrolled people**. With one, a four-eyes action
  is not offered on the phone. The console, with the process key, remains
  the fallback, as it is for everything today.
* Without push, the second person learns of a request in the app ("1
  Freigabe offen" on Heute) or by a call. With phase 3, a push without
  content ("Bridge: Freigabe") tells them and opens "Freigaben".

### Audit

New events in the catalog:

* `mobile_device_enrolled`, `mobile_device_approved`, `mobile_device_revoked`;
* `mobile_action_requested`, `mobile_action_approved`;
* `mobile_action_closed`, with the reason: rejected, withdrawn, expired
  or voided by a changed plan;
* `mobile_action_executed`.

`mobile_action_executed` records:

* the caller and the device;
* the approver, where there was one;
* the action, the preview and the plan hash;
* the target MP-IDs;
* the result.

The `mobile_*` events are already kept out of Slack: the Cloover feed
drops the prefix, and alerting fires only for the types it names. They stay
out of the app's own event feed as well. The middleware's `api_call` event
records every state-changing call with its caller, as it does today. The
service functions themselves stay unaware of who called them.

### The app

* **Heute** gains "Aktionen" and, where requests are open, "Freigaben".
  The preview shows the server's plan word for word, with "Senden" or "Zur
  Freigabe senden"; each one asks for the fingerprint or face.
* It keeps no queue and makes no automatic retry. A tap on "erneut
  versuchen" repeats the request with the same idempotency key.
* The watch, the widgets and the Live Activity stay read-only.

### Threats

| Threat | Covered by |
| - | - |
| The read key leaks | It reads; it cannot sign. |
| The phone is stolen while unlocked | Every signature needs biometrics; revoke without a restart. |
| A replayed or reordered request | Single-use preview, idempotency key, ±60 s, the signature over the plan hash. |
| The data changes between preview and execute | The dry run runs again under the lock; `409 plan_changed`. |
| One person sends to partners alone, by mistake or under pressure | Four eyes for the actions marked so; target cap; audit. |
| A partner appears between preview and send | Execute sends only to the plan's targets (`nur=`). |
| A crash or lost answer during execute | The idempotency key, stored as pending before sending: no second send. |
| A repackaged app | Android attestation checks the package and the signing certificate. On iOS it is an accepted residual risk (see Identity). |
| The process-key holder enrols a second handle to approve their own requests | Every approval is audited and listed in the console; a second person approving enrolments (decision 4). Whoever holds the process key can already send everything through the console. |
| A race with the cron | The shared send lock. |
| The process key leaks | Out of scope here: it already allows everything. |

## Phase 3: native push

Push tells the operator that something happened; the text is only read in
the app.

* **Payloads carry no market data.** They hold a fixed German title per
  level - "Bridge: Fehler", "Bridge: Warnung" and, with phase 2, "Bridge:
  Freigabe" - plus the event id. The app shows the text after it is opened,
  from `/v1/mobile/events`.
* A Notification Service Extension that fetches the text is not proposed,
  because it would be one more target holding the key.
* **What triggers a push** follows the existing rules in `alert_for`:
  EMERGENCY gives "Bridge: Fehler", HIGH gives "Bridge: Warnung", and
  NORMAL never pushes. There is one push per event id.
  * `Alert` carries no event id, so the push notifier works from the
    event itself, through the event log's subscriber hook.
  * "Bridge: Freigabe" is not an alert: it carries the request id and
    opens "Freigaben", because `mobile_*` events never reach the app's
    event feed.
* **Pushover stays** as the EMERGENCY backstop. APNs and FCM cannot repeat
  until acknowledged.
* **Tokens.** `POST` and `DELETE /v1/mobile/push/token` write only the
  token store. That is the one named exception to the read-only test of
  `/v1/mobile`.
  * A watch key or a revoked device gets no token.
  * Each caller has at most three tokens, and a fourth is refused rather
    than replacing one.
  * Tokens are deleted on revocation and on a wipe; a token APNs reports
    as unregistered is dropped, and the console can clear a device's
    tokens.

<Tabs>
  <Tab title="iOS: APNs">
    - An `ApnsNotifier` next to the existing notifiers. Its settings:
      * a `.p8` token key from the environment, in `SECRET_FIELDS`;
      * an ES256 JWT, renewed before its hour runs out;
      * HTTP/2 with a 10 s timeout. This needs `h2` as a new dependency.
    - A notification category with `hiddenPreviewsBodyPlaceholder`.
    - The `time-sensitive` interruption level for EMERGENCY, which needs
      its entitlement.
    - The watch mirrors the iPhone's notifications; it gets no push of its
      own.
    - **The Live Activity** could then change colour while the app is
      closed, through its own push token. Its content state holds the
      badge, the closure, the data's time and LIVE/TEST - no energy, no
      partners - but Apple would see it. That needs a decision of its own.
  </Tab>

  <Tab title="Android: FCM or Pushover">
    **FCM**, only if Google is accepted as a processor:

    * only the native `firebase-messaging` behind an own channel, with no
      FlutterFire and so no Firebase in the iOS build;
    * `firebase_messaging_auto_init_enabled=false`, the token requested
      only after key setup and deleted on a wipe;
    * `VISIBILITY_PRIVATE` with a generic public version;
    * the service-account JSON in `SECRET_FIELDS`.

    FCM would be the app's first Firebase or Google Play services
    dependency. The manifest check would have to allow `POST_NOTIFICATIONS`
    and FCM's own permissions.

    **Pushover**, as today: no new processor and no new code, but a tap
    does not open the app.

    The proposal: **keep Pushover on Android for now**, and decide on FCM
    when Android users ask for push into the app.
  </Tab>
</Tabs>

## Order, once approved

Each step is its own PR, with green CI and an independent review.

1. **2a - Device registry and revoke without restart.** Adds the denylist
   for read keys and the console route. It is useful at once for a lost
   phone.
2. **2b - Enrolment.** Adds the challenge, Android attestation, approval
   in the console with the fingerprint, and the app's `bridge/signing`
   channel. No action yet.
3. **2c - The actions router.** Brings the allowlist test, preview and
   execute, the send lock, idempotency, the `nur=` parameter on the
   sending functions, the `dry_run` guard for `aperak_needs_review` and the
   audit events. Its first action is **start the service**, the one with
   the least risk.
4. **2d - Stop the service** with the gate rule, and the
   **Protokoll-Notiz**.
5. **2e - Four-eyes**, with **answer incoming PARTINs** as its first
   action.
6. **2f - The remaining actions**, one per PR.
7. **3a - The token store and APNs**, then the Live Activity push if
   approved. Android follows the FCM decision.

## Open decisions

<Steps>
  <Step title="Four eyes per action">
    The table above is a proposal. In particular: should the APERAK
    acknowledgement, which goes to market partners, and the Notiz need
    four eyes as well?
  </Step>

  <Step title="Four-eyes requests">
    30 minutes of life, and voided when the plan changes. Should a request
    also lapse at the gate closure?
  </Step>

  <Step title="T+1 final">
    For the stop rule: is T+1 final when its badge code is `accepted` or
    `imbalance`?
  </Step>

  <Step title="Enrolment approval">
    Is the console approval by whoever holds the process key enough, or
    should it be a second person?
  </Step>

  <Step title="Target cap">
    20 targets per execution, or another number?
  </Step>

  <Step title="Wiedervorlagen">
    Not in phase 2 (proposal), or closing without moving the mail?
  </Step>

  <Step title="Android push">
    FCM with Google as a processor, or Pushover as today (proposal)?
  </Step>

  <Step title="Live Activity push">
    May the badge state go through APNs?
  </Step>

  <Step title="Identifiers">
    The attestation check needs the final Android application ID (Q11:
    `ch.nlead.bridge_app` is a placeholder) and its signing-certificate
    digest. APNs needs the iOS bundle ID (Q10).
  </Step>
</Steps>


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