Appearance
Theseus ops inbox (safecall-ops)
Approved: 17 September 2026 (#153)
Amended: 11 October 2026: the inbox is a tool on the start screen and lives while Theseus is unlocked. The connection heals itself, and a new event shows as a banner on whatever screen is open. See Lifetime and Banner.
Wantok-unlocked Theseus shows a foreground inbox for the vendor ops topic. This is not a Keryx staff category, not a site QR, and not a field token.
Layer 1 (Wantok unlock) stays in front. The unlock screen has no inbox. Pairing (site QR) is optional; the inbox works without it.
The inbox is the Ops inbox tool on the start screen. Its card shows the stream state, the events not seen yet (3 new) or else the number of events, and the newest title.
Purpose and non-goals
Purpose
- Subscribe to
{binary_name}-ops(defaultsafecall-ops) while Theseus is unlocked and in the foreground. - Show ntfy events as a list + detail. Parse
SafeCall alert data v1when present; keep legacy bodies readable. - Name where the production subscribe credential lives.
Non-goals
- FCM / APNs / killed-state delivery / public-store GTM.
- Staff topics (
TST000-sos, Keryx QR categories). - Ack / assign / resolve. Archive / delete workflow.
- Changing
ntfy_publish_ops_healthpublishers. - A shared Dart package with Keryx. A customer-box
wantokrole.
Approved decisions
| Choice | Value |
|---|---|
| Topic | {binary_name}-ops, default safecall-ops. Not a Keryx category; omitted from GET /features and Keryx QR mint |
| Host | Company notify URL (server_ntfy_ops_server_url). Not the customer site ntfy URL from a Keryx QR |
| Auth | Authorization: Bearer tk_… subscribe-only. Never th_, never server_mobile_api_key, never a Keryx QR subscribe_auth |
| Release | --dart-define=THESEUS_OPS_SERVER_URL=… and --dart-define=THESEUS_OPS_SUBSCRIBE_AUTH=…. Optional --dart-define=THESEUS_OPS_TOPIC=safecall-ops (defaults to that name). Empty URL or auth fails closed (no anonymous subscribe) |
| Debug / test | Injectable OpsConfig. No production host and no real tk_ in git. Unset defines show Ops not configured |
| Transport | Foreground HTTP NDJSON GET {url}/{topic}/json?since=all. Opened at unlock, closed while the app is in the background, cancelled on lock / app close. Reconnects by itself. No package:ntfy |
| Parser | Thin copy of Keryx SafeCall alert data v1 parse. List hides the transport block |
| Persistence | In memory while Theseus is unlocked, at most 500 events (oldest dropped). Gone on lock / app close. ntfy since=all fills the recent cache |
Owner: Thomas Minitsios. Store: the approved Keryx / sourcectl secret store. Do not invent a second store.
Publish path stays on the SafeCall box (server_ntfy_ops_* / ntfy_publish_ops_health). Ops titles are already prefixed with {server_id}:.
Lifetime
One app-level TheseusOpsInboxController (theseus/lib/ops/ops_inbox_controller.dart) owns the subscription and the in-memory list. The inbox screen and the start screen card only render from it.
| Moment | What happens |
|---|---|
| Unlock (or cold start already unlocked) | The app creates the controller. It subscribes once. No config: stays Not configured, no request (fail closed; a config with an empty URL, topic or token counts as none) |
| Start screen, or inside another tool | Events keep arriving. The Ops inbox card shows Connecting… / Live / Reconnecting…, the events not seen yet or the number of events, and the newest title |
| Inbox screen opened / closed | Nothing starts or stops. The list is the controller's list. While the inbox is on screen every event counts as read |
| Connection drops, or the connect fails | Status becomes Reconnecting…; events so far are kept. Theseus connects again by itself after 2 s, then 5, 15, 30 and every 60 s for as long as it is unlocked and in the foreground. The backoff starts over once it is live. Retry now on the inbox screen connects at once |
| Connect gets no answer | Given up on after 15 s; the retry loop takes over |
| Connection goes silent | ntfy sends a keepalive line about every 45 s. No line at all for 100 s means the connection is dead (a half-open socket after a Wi-Fi roam): it is dropped and the retry loop takes over |
| Subscribe refused (HTTP 401 / 403) | Refused: the notify host does not accept the subscribe token of this build. Not retried automatically; Retry on the inbox screen still works |
App goes to the background (paused) | The connection is closed and nothing retries: the stream is foreground only |
| Back from the background | Theseus subscribes again at once. A system dialog (inactive only) does not reconnect |
| Reconnect | ntfy replays its recent cache (since=all); a replayed event replaces its copy, no duplicates. What the inbox did not have yet was missed during the outage: it counts as new |
| Lock Theseus, or app close | The controller is disposed: stream cancelled, list dropped, no retry pending |
Still foreground only: no FCM / APNs, nothing arrives while the app is suspended or closed. Still in memory only: nothing is written to disk.
What counts as new
- The cache ntfy replays when Theseus first connects after an unlock is history: it is listed, not counted as new. The card shows the plain number of events in a quiet badge.
- Everything that arrives later, and everything a reconnect brings that the inbox did not have, is new: the card badge says
3 new. - Opening the inbox reads everything.
- ntfy does not mark where its replay ends, so the first 5 s of a connection count as replay. An event published in exactly those 5 s after a first connect is treated as history.
Banner
A notification has to reach the user wherever they are in the app, also inside a tool. TheseusOpsToastHost (theseus/lib/widgets/ops_toast.dart) sits above the navigator and shows what the controller announces:
| Announcement | Banner |
|---|---|
| A new event | Its title, Ops inbox · tap to open. An alert that is not resolved is drawn in the error colours with a strong haptic; anything else neutral with a light one |
| Events missed during an outage | One banner when the reconnect has replayed them: the newest title and 3 new ops events · tap to open |
| The stream has been down for a while (three failed reconnects, about 20 s) | Ops inbox disconnected, once per outage |
| The stream is back after that | Ops inbox live again |
- The banner sits under the app bar, never over the bottom of the screen: the bottom belongs to a tool's controls (the recorder's Moving button), the app bar to back and the tool's actions.
- It leaves by itself after 6 s, on its close button, or on a tap, which opens the inbox above the current screen.
- Events that arrive while a banner is up are counted on it.
- Nothing is shown while the inbox itself is open, and nothing on the unlock screen.
Dev vs release
Debug / widget tests inject TheseusOpsConfig (fake host + fake Bearer tk_theseus_ops_dev). They never call the production notify host.
Release builds (kReleaseMode) require both dart-defines. Empty or missing URL or auth: the inbox stays on Ops not configured and opens no HTTP request.
Internal/release flutter build must pass the URL and subscribe header from the NAS notes + Apple Passwords. Do not commit tk_….
Operator row: OA-AT-ops (done 17 September 2026: user safecall-ops-subscriber, token label safecall-ops; token in Apple Passwords; notes on NAS secrets/theseus/ops/token_label_notes.txt).
How to mint the subscribe token
On hetest2, ntfy needs sudo (/etc/ntfy/server.yml is not world-readable). Create a dedicated user with read-only on safecall-ops only. Do not reuse safecall-token-manager (* read-only). Do not put that topic on a Keryx QR.
bash
sudo ntfy user add safecall-ops-subscriber
sudo ntfy access safecall-ops-subscriber "safecall-ops" read-only
sudo ntfy token add --label="safecall-ops" safecall-ops-subscriberCopy host / username / token-label notes (not the token) to /srv/sourcectl/secrets/theseus/ops/ (file token_label_notes.txt, 17 September 2026). Put the tk_… value in Apple Passwords (item NTFY; username safecall-ops-subscriber; Notes field. The password field holds the ntfy login password).
Threat notes
| Issue | Treatment |
|---|---|
| Stolen APK / backup contains dart-define auth | Token is a subscribe secret. Rotate on the notify host; rebuild Theseus. |
| Anonymous subscribe to production notify | Fail closed: no request without auth. Dual-run anonymous read is not a Theseus fallback. |
| Site QR / field token used as ntfy auth | Rejected by construction. Ops config is Wantok-global dart-define, not T3 prefs. |
| Staff categories in the inbox | Stream requests only the ops topic. |
| Lost unlocked phone | Foreground inbox until Theseus is locked or app data is cleared. No FCM in this window. |
Current gap
T5 adds field APIs. Do not subscribe to Keryx staff topics from Theseus.