title: "Was koch ich draus? — Live-Activity Content-State-Vertrag (SILV-325)" category: dev date: 2026-06-18
Live-Activity Content-State-Vertrag: Co-Plan „Was koch ich draus?"
Wer: StratoClaude (Backend, pusht den State) → MacClaude (App baut das Widget
CookSuggestActivity). Schwester zu live-activity-contract.md (Shopping) und
live-activity-budget-contract.md (Budget). Charter-Regel: Backend liefert den
State, die App rendert ihn. Datenfluss: cook-suggest-vertrag.md.
Die LA begleitet die Co-op-Session zu zweit („Plan mit Jaqueline das Essen"): der eine startet, der andere bekommt pushToStart + Push, beide sehen den Fortschritt live, tippen sich per Deep-Link in den jeweils richtigen Wizard-Schritt.
ActivityAttributes-Typ: CookSuggestActivityAttributes
Statische attributes (beim Start, ändern sich nicht):
| Key | Typ | Bedeutung |
|---|---|---|
sessionId |
String | Session-ID (Token-Lookup + Deep-Link-Ziel) |
date |
String (ISO YYYY-MM-DD) |
Tag, für den geplant wird |
partnerName |
String? | Name des/der anderen (Dynamic Island) |
partnerTint |
String? | Farb-Token des Partners (users.color) |
partnerEmoji |
String? | Profil-Emoji des Partners (users.emoji) |
Dynamischer content-state (Start, Update):
| Key | Typ | Bedeutung |
|---|---|---|
state |
String | Session-State (collecting…decided), 1:1 wie CookSessionState |
statusLabel |
String | server-formuliert, der EINE Satz: „Jaqueline füllt die Fragen aus" · „Beide bereit — die Küche denkt nach …" · „3 Vorschläge sind da" · „Jaqueline schlägt Linsen-Bolognese vor" · „Entschieden: Linsen-Bolognese" |
deepLinkStep |
String? | wohin der Tap führt: wizard · waiting · proposals · decided (Client mappt auf den Screen/Schritt) |
proposalCount |
Int | „N Vorschläge bereit" (0 außer in proposed/danach) |
partnerPickTitle |
String? | Titel des aktuellen Pick des Partners („Vorschlag: …?") — null, solange er nicht gewählt hat |
partnerReady |
Bool | Partner hat den Warteraum bestätigt (für den „der andere wartet"-Beat) |
decidedTitle |
String? | gesetzt in decided — der Abschluss-Beat |
updatedAt |
String? | YYYY-MM-DD HH:MM:SS (Europe/Berlin) — Client verwirft ältere Pushes (Flacker-Schutz) |
rev |
Int | monotone Session-Revision, +1 je Mutation — Client guardet/reconciled gegen rev (killt den Sekunden-Tie-Race, wie Shopping SILV-193) |
ended |
Bool? | bei decided/cancelled: finaler Push als APNs-end-Event (~2 min Nachklang), dann räumt iOS die Karte ab |
Swift-Vorlage (Mac baut):
struct CookSuggestActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var state: String
var statusLabel: String
var deepLinkStep: String?
var proposalCount: Int
var partnerPickTitle: String?
var partnerReady: Bool
var decidedTitle: String?
var updatedAt: String?
var rev: Int
var ended: Bool?
}
var sessionId: String
var date: String
var partnerName: String?
var partnerTint: String?
var partnerEmoji: String?
}
aps-Payload (analog Shopping/Budget):
{ "aps": { "timestamp": 1234567890, "event": "start|update|end",
"content-state": { "state": "proposed", "statusLabel": "3 Vorschläge sind da",
"deepLinkStep": "proposals", "proposalCount": 3,
"partnerPickTitle": null, "partnerReady": true,
"decidedTitle": null, "updatedAt": "2026-06-18 19:12:03",
"rev": 7, "ended": null },
"attributes-type": "CookSuggestActivityAttributes",
"attributes": { "sessionId": "cs_abc", "date": "2026-06-18",
"partnerName": "Jaqueline", "partnerTint": "coral", "partnerEmoji": "🐠" } } }
context_key & Token-Lookup
context_key = "cooksuggest:{sessionId}:{userId}"— per User (nicht je Session), weil der Partner-Bezug (partnerName/statusLabel„Jaqueline ist bereit") aus Sicht jedes Geräts ein anderer ist und einupdate_by_context-Push EINE Payload an alle Token mit dem Key schickt. Der Client registriert denla-update-Token mitcontext_key = "cooksuggest:\(sessionId):\(myUserId)"(POST /api/apns/register,kind:"la-update"). Der Server pusht je present-Teilnehmer dessen eigene Payload (Partner = der jeweils andere).- pushToStart (Co-op-Einladung): beim
createmitwith_user_idstartet der Server die LA auf den Geräten des eingeladenen Partners (la-pushToStart- Tokens dieses Users, nicht broadcast). Idempotent je(sessionId, deviceToken)überla_starts. Setzt voraus, dass der Partner einenla-pushToStart-Token registriert hat (POST /api/apns/register) — ein 500 dort (SILV-326, gefixt) verhinderte die Co-op-LA komplett. - Einladungs-Zustellung (3 Kanäle, ein Aufruf
notify()): zusätzlich zur LA feuert der Server eine Benachrichtigung „Plan mit Dennis das Essen" (Sparkle-Icon ✨, emoji-freier Titel, kein 🤖) — die (a) als Notification- Center-Record in der „Heute"-Glocke landet (das bestehendenotifications- Polling zeigt sie), (b) als Alert-Push mit Deep-Link im Top-Level-Feldlink = "/cook-suggest/{sessionId}"(der Client liestuserInfo["link"]im Notification-Tap und routet in den Assistenten — gleiche Form wie Shopping/Budget), (c) als Web-Push. Idempotent überdedupe_key = "cs-invite-{sessionId}". - Einladung ausgrauen bei Session-Ende (C1): erreicht die Session
decided/cancelled(TTL/letzterleave), setzt der Servernotifications.resolved = 1auf den Einladungs-Record. Der Client liestresolved(0/1) und graut die Glocken-Karte aus — ein Tap auf eine veraltete Einladung öffnet sonst die Planung „von vorne". (Der Client fängt „resume einer beendeten Session" zusätzlich ab:decided→ Gericht zeigen,cancelled→ schließen.) - Solo (kein
withUserId): keine Remote-LA nötig — der Initiator startet die Karte lokal selbst (er ist ja in der App). Push-LA ist rein das Co-op-Mittel.
Push-Trigger (welcher Zustandswechsel pusht)
Der Server pusht bei jeder Session-Mutation (voller Content-State, kein Delta):
- answers/ready → statusLabel („… füllt aus" / „… ist bereit"), partnerReady.
- alle ready → generating → „Die Küche denkt nach …".
- Job fertig → proposed → „N Vorschläge bereit", proposalCount, deepLinkStep:"proposals".
- pick → partnerPickTitle (der andere sieht „Vorschlag: …?").
- confirm-Einigung → finalizing → „Wird angerichtet …".
- decided → decidedTitle, ended:true (APNs end, ~2 min).
- cancelled/letzter leave/TTL → ended:true (end).
Ehrlichkeit / Reconcile (wie Shopping SILV-193)
Kein Push ist eine Zustell-Garantie (APNs fire-and-forget). Darum: monotone
rev im Content-State + der Client zieht beim Push-Aufwachen / Deep-Link den
vollen Zustand via GET /api/cook-suggest/sessions/{id} (= Wahrheit). Push =
Wecker, Pull = Wahrheit. Der Client guardet seine lokalen Updates gegen rev.