From d853be2f181ba04778ebf910d6f83474611f4892 Mon Sep 17 00:00:00 2001 From: Eason WaveKat Date: Thu, 30 Jul 2026 22:37:33 +1200 Subject: [PATCH 1/2] feat(voice): add deleted_at to VoiceCallRecord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the call-delete tombstone. Calls are otherwise immutable one-way pushes; this is the one exception, because a hard DELETE can't sync under a push-the-row model — so a delete rides as an ordinary upsert with deletedAt set, same as the account tombstone already does. Two things a consumer needs to know, documented on the field: the platform treats this tombstone as *sticky* (COALESCE, not last-write-wins — a call has no updatedAt to resolve on), so re-syncing with None can never undelete; and deleting also destroys the recording bytes, the transcript, and any live share link, not just the flag. VoiceCallsQuery gains includeDeleted so a device can pull tombstones and reap its local copies. Step 2 of the three-repo train — wavekat-platform accepts the field already; wavekat-voice needs a release of this before it can build. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EEyoayhGVRqCCY9BWJTKDD --- crates/wavekat-platform-client/src/voice.rs | 122 ++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/crates/wavekat-platform-client/src/voice.rs b/crates/wavekat-platform-client/src/voice.rs index 6b883ba..d8b55b5 100644 --- a/crates/wavekat-platform-client/src/voice.rs +++ b/crates/wavekat-platform-client/src/voice.rs @@ -253,6 +253,36 @@ pub struct VoiceCallRecord { /// row renders a trace and it would weigh down every page. #[serde(default, skip_serializing_if = "Option::is_none")] pub flow_steps: Option>, + /// RFC 3339 soft-delete tombstone. `None` = live; `Some` = the user + /// deleted this call at that time. + /// + /// Calls are otherwise immutable one-way pushes, and this is the + /// single exception: a delete has to reach the platform somehow, and + /// a hard `DELETE` can't sync under a "push the row" model — once + /// the row is gone there's nothing left to push. So a delete rides + /// as an ordinary upsert with this field set, exactly like + /// [`VoiceAccountRecord::deleted_at`]. + /// + /// Where it differs from the account tombstone: **the platform + /// treats this one as sticky, not last-write-wins.** An account is + /// genuinely mutable, so it carries `updated_at` and conflicts + /// resolve on it; a call has no such field because delete is the + /// only mutation it has. The platform resolves the column + /// `COALESCE(existing, incoming)`, so once a call is deleted a + /// later sync of the same `source_id` can never revive it — which + /// also means a consumer must not expect to "undelete" by syncing + /// the row again with `None`. + /// + /// Deleting a call is not only a flag on the platform side: the + /// recording bytes are removed from object storage, the recording + /// and transcript rows are dropped, and any live share link is + /// revoked (it answers 410 thereafter). The tombstone row is + /// retained so a late-syncing device still learns about the delete + /// — read it via `include_deleted` on + /// [`VoiceCallsQuery`]. `GET /api/voice/calls/{sourceId}` returns + /// 404 for a deleted call rather than echoing the tombstone. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, /// Version + forward-compat fields shared by every sync record. /// Flattened so `schemaVersion` and `extras` sit at the top of /// the JSON object alongside the other columns. See @@ -267,6 +297,18 @@ pub struct VoiceCallRecord { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VoiceCallsQuery { + /// Include soft-deleted tombstones in the response. Absent / false + /// returns only live calls — what a human-facing list wants. A + /// delta-syncing device sets this `true` to learn about deletes + /// made on another device or on the web, so it can reap its local + /// copy. + /// + /// Unlike [`VoiceAccountsQuery::include_deleted`] there is no + /// "restore a fresh device" use for this: a tombstoned call has had + /// its recording and transcript destroyed, so the only thing left + /// to learn from it is that it's gone. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include_deleted: Option, /// RFC 3339 cursor; rows with `end_at < before` are returned. #[serde(default, skip_serializing_if = "Option::is_none")] pub before: Option, @@ -1139,6 +1181,7 @@ mod tests { flow_name: None, flow_outcome: None, flow_steps: None, + deleted_at: None, envelope: SyncEnvelope::for_endpoint::(), }; let s = serde_json::to_string(&r).unwrap(); @@ -1168,6 +1211,85 @@ mod tests { // `extras` is None, so the envelope contributes no `extras` // key. Stays out of the row to keep the small/fast path. assert!(!s.contains("\"extras\""), "extras should be omitted: {s}"); + // A live call omits the tombstone entirely rather than sending + // `null` — every ordinary sync is a live call, so this is the + // common path and it should stay off the wire. + assert!( + !s.contains("\"deletedAt\""), + "deletedAt should be omitted on a live call: {s}" + ); + } + + #[test] + fn call_tombstone_serializes_deleted_at() { + // The delete-propagation mechanism: a deleted call rides up as + // an ordinary upsert with `deletedAt` set (platform docs/22), + // the same shape the account tombstone uses. + let mut r = VoiceCallRecord { + source_id: "11111111-1111-4111-8111-111111111111".into(), + account_id: "22222222-2222-4222-8222-222222222222".into(), + direction: VoiceCallDirection::Inbound, + party: "+14155550123".into(), + ring_at: "2026-05-16T10:00:00Z".into(), + answer_at: None, + end_at: "2026-05-16T10:01:00Z".into(), + duration_ms: None, + disposition: VoiceCallDisposition::Missed, + end_reason: VoiceCallEndReason::HangupRemote, + error: None, + share_visibility: None, + transfer_target: None, + codec: None, + flow_id: None, + flow_name: None, + flow_outcome: None, + flow_steps: None, + deleted_at: None, + envelope: SyncEnvelope::for_endpoint::(), + }; + r.deleted_at = Some("2026-07-30T12:00:00Z".into()); + let s = serde_json::to_string(&r).unwrap(); + assert!(s.contains("\"deletedAt\":\"2026-07-30T12:00:00Z\""), "{s}"); + } + + #[test] + fn call_record_parses_without_deleted_at() { + // Reading back a live call from `GET /api/voice/calls`: the + // platform sends `deletedAt: null`, and a platform build + // predating the field sends nothing at all. Both must land as + // `None` rather than failing the whole page. + let raw = r#"{ + "sourceId": "a", + "accountId": "b", + "direction": "outbound", + "party": "+14155550123", + "ringAt": "2026-05-16T10:00:00Z", + "endAt": "2026-05-16T10:01:00Z", + "disposition": "answered", + "endReason": "hangup_local" + }"#; + let parsed: VoiceCallRecord = serde_json::from_str(raw).unwrap(); + assert!(parsed.deleted_at.is_none()); + + let with_null: VoiceCallRecord = + serde_json::from_str(&raw.replace('}', r#", "deletedAt": null }"#)).unwrap(); + assert!(with_null.deleted_at.is_none()); + } + + #[test] + fn calls_query_serializes_include_deleted() { + // The delta-pull flag a device sets to learn about deletes made + // elsewhere. Omitted when unset, so an ordinary list request is + // unchanged. + let live = VoiceCallsQuery::default(); + assert_eq!(serde_json::to_string(&live).unwrap(), "{}"); + + let delta = VoiceCallsQuery { + include_deleted: Some(true), + ..Default::default() + }; + let s = serde_json::to_string(&delta).unwrap(); + assert!(s.contains("\"includeDeleted\":true"), "{s}"); } #[test] From 1774dcb68133ee84857071ecb0299c4fbc572f25 Mon Sep 17 00:00:00 2001 From: Eason WaveKat Date: Sat, 8 Aug 2026 19:57:40 +1200 Subject: [PATCH 2/2] feat(voice): booking calls and flow schema negotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions the daemon needs before it can run a `book` step (wavekat-platform docs/30 §4). **The booking pair.** `booking_slots` and `booking_book` — "when is this business free?" and "put the caller in at this time". Unlike everything else in this file these are synchronous and in-call: nothing is queued, batched or retried, because a person is on the line. The calendar credential never reaches this crate; the platform holds the connection and answers in times and outcomes, which is what makes booking two platform calls rather than a Google client in every daemon. Both routes speak snake_case, unlike the camelCase sync resources, so these types carry no `rename_all` — with a test pinning it, since a camelCased body is rejected mid-call and the flow can only render that as "unavailable". `status` stays a string rather than an enum: failing to deserialize an unknown status would drop a live call. **`VoiceFlowsQuery::schema_versions`.** The caller states which document versions its flow engine can run, and the platform withholds the rest rather than serving something that fails to parse and leaves a line unarmed. `None` is not "anything goes" — the platform reads a missing value as version 1 only, because the parameter arrived alongside version 2. Explicitly renamed to the server's `schema_versions`: the struct is camelCase, and a silently camelCased key is ignored by the server, which reads exactly like an account with no flows in that version. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018stjkZJ4kxDViq1ENnVDmH --- crates/wavekat-platform-client/src/lib.rs | 18 +- crates/wavekat-platform-client/src/voice.rs | 283 ++++++++++++++++++++ 2 files changed, 293 insertions(+), 8 deletions(-) diff --git a/crates/wavekat-platform-client/src/lib.rs b/crates/wavekat-platform-client/src/lib.rs index bd820de..b464011 100644 --- a/crates/wavekat-platform-client/src/lib.rs +++ b/crates/wavekat-platform-client/src/lib.rs @@ -56,12 +56,14 @@ pub use sign::{ pub use sync::{HasSyncEnvelope, Page, SyncEndpoint, SyncEnvelope, SyncRequest, SyncResponse}; pub use token::Token; pub use voice::{ - InstallHeartbeatRequest, InstallHeartbeatResponse, PartyMasking, ShareRecordingRequest, - ShareRecordingResponse, ShareVisibility, SystemInfo, VoiceAccountRecord, VoiceAccounts, - VoiceAccountsQuery, VoiceCallCodec, VoiceCallDirection, VoiceCallDisposition, - VoiceCallEndReason, VoiceCallFlowOutcome, VoiceCallFlowStep, VoiceCallRecord, VoiceCalls, - VoiceCallsQuery, VoiceFlowAssetsPage, VoiceFlowRecord, VoiceFlowVersionAsset, VoiceFlowsPage, - VoiceFlowsQuery, VoiceRecordingRecord, VoiceRecordingSyncItem, VoiceRecordings, - VoiceRecordingsQuery, VoiceRecordingsSyncResponse, VoiceTranscriptChannel, - VoiceTranscriptRecord, VoiceTranscripts, VoiceTranscriptsQuery, VoiceTransport, + BookingBookRequest, BookingBookResponse, BookingException, BookingSchedule, BookingSlot, + BookingSlotsRequest, BookingSlotsResponse, BookingTimeRange, InstallHeartbeatRequest, + InstallHeartbeatResponse, PartyMasking, ShareRecordingRequest, ShareRecordingResponse, + ShareVisibility, SystemInfo, VoiceAccountRecord, VoiceAccounts, VoiceAccountsQuery, + VoiceCallCodec, VoiceCallDirection, VoiceCallDisposition, VoiceCallEndReason, + VoiceCallFlowOutcome, VoiceCallFlowStep, VoiceCallRecord, VoiceCalls, VoiceCallsQuery, + VoiceFlowAssetsPage, VoiceFlowRecord, VoiceFlowVersionAsset, VoiceFlowsPage, VoiceFlowsQuery, + VoiceRecordingRecord, VoiceRecordingSyncItem, VoiceRecordings, VoiceRecordingsQuery, + VoiceRecordingsSyncResponse, VoiceTranscriptChannel, VoiceTranscriptRecord, VoiceTranscripts, + VoiceTranscriptsQuery, VoiceTransport, }; diff --git a/crates/wavekat-platform-client/src/voice.rs b/crates/wavekat-platform-client/src/voice.rs index d8b55b5..792e81f 100644 --- a/crates/wavekat-platform-client/src/voice.rs +++ b/crates/wavekat-platform-client/src/voice.rs @@ -641,6 +641,27 @@ pub struct VoiceFlowsQuery { /// Page size, server-capped at 100. `None` = server default (50). #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option, + /// The document versions this caller's flow engine can run — + /// `wavekat_flow::SUPPORTED_SCHEMA_VERSIONS`, comma-separated + /// ascending ("1,2"). The platform withholds documents in any other + /// version rather than serving one the caller would fail to parse. + /// + /// **Send it.** `None` does not mean "anything goes": the platform + /// reads a missing value as version 1 only, because this parameter + /// arrived alongside version 2 and a caller that omits it is an + /// older build. A client that can run a newer version and stays + /// quiet silently loses those flows. + // + // Explicitly renamed: the struct is camelCase overall, but this + // route's query parameter is `schema_versions`, and a silently + // camelCased key would be ignored by the server — which reads + // exactly like a platform that has no such flows. + #[serde( + rename = "schema_versions", + default, + skip_serializing_if = "Option::is_none" + )] + pub schema_versions: Option, } /// One page of published flow snapshots. @@ -727,6 +748,182 @@ impl Client { } } +// ---- Booking (mid-call, synchronous) --------------------------------------- +// +// The action plane of wavekat-platform's docs/30: a `book` step asking +// "when is this business free?" and then "put the caller in at this +// time", with the caller on the line. +// +// Unlike every other endpoint in this file, these are **synchronous and +// in-call**. Nothing here is queued, batched or retried: a person is +// waiting, so the platform answers within seconds or answers +// `unavailable`, and the flow takes its fallback exit. Callers should +// give these a short timeout of their own and treat expiry the same way +// they treat `unavailable`. +// +// The calendar credential never reaches this crate. The platform holds +// the connection and answers in times and outcomes — which is what makes +// booking a pair of platform calls rather than a Google client in every +// daemon. +// +// Wire note: these routes use `snake_case` bodies, unlike the camelCase +// sync resources above, so these types carry no `rename_all`. + +/// One open window in a business's week, `"HH:MM"` 24-hour local time — +/// the same shape the flow document's `hours`/`book` steps carry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BookingTimeRange { + pub open: String, + pub close: String, +} + +/// Open windows per weekday. A missing or empty day is closed. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BookingSchedule { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mon: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tue: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub wed: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub thu: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fri: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sat: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sun: Vec, +} + +/// A single-date override of the weekly schedule (a holiday, or special +/// hours). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BookingException { + /// `"YYYY-MM-DD"` in the schedule's own timezone. + pub date: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub closed: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ranges: Vec, +} + +/// Body of `POST /api/voice/booking/slots`. +/// +/// Everything except `source_id` comes straight off the flow document's +/// `book` step; the platform holds no per-node configuration of its own. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BookingSlotsRequest { + /// The call this offer belongs to (`voice_calls.source_id`). Slots + /// are held against it, which is what stops a caller being blocked + /// by their own offers — and what stops a second caller being + /// offered the same time. + pub source_id: String, + pub duration_mins: u32, + #[serde(default)] + pub buffer_mins: u32, + #[serde(default)] + pub lead_mins: u32, + #[serde(default)] + pub horizon_days: u32, + pub schedule: BookingSchedule, + /// IANA zone the schedule is written in. + pub timezone: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exceptions: Vec, + /// How many times to offer. The answer may be shorter, never longer. + pub limit: u32, +} + +/// One offerable appointment, as absolute RFC 3339 instants. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BookingSlot { + pub start: String, + pub end: String, +} + +/// Answer to `POST /api/voice/booking/slots`. +/// +/// `slots` empty is a real answer — the calendar is full, or the window +/// closed — and not an error: the flow takes its no-slots exit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BookingSlotsResponse { + #[serde(default)] + pub slots: Vec, + /// The zone the times should be *spoken* in — the business's, echoed + /// back so the caller isn't told a time in the server's zone. + #[serde(default)] + pub timezone: String, + /// Set when the platform could not read the calendar at all + /// (`"unavailable"`); `slots` is then empty and the reason is for + /// logs, never for a caller. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// Body of `POST /api/voice/booking/book`. +/// +/// Idempotent on `source_id`: a retried request for a call that already +/// has an appointment answers `booked` with the existing event's start, +/// without touching the calendar. A timed-out request is therefore safe +/// to repeat. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BookingBookRequest { + pub source_id: String, + /// One of the `start`s `/slots` handed back, verbatim. + pub start: String, + pub duration_mins: u32, + pub timezone: String, + /// Who is booking, for the calendar entry. Empty when the call + /// carried no caller id. + #[serde(default)] + pub caller_number: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub caller_name: Option, +} + +/// Answer to `POST /api/voice/booking/book`. +/// +/// Three outcomes, and the flow does something different with each: +/// `booked` continues, `slot_taken` can offer again, `unavailable` falls +/// back. Left as a string rather than an enum so a status added later +/// deserializes instead of failing the call. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BookingBookResponse { + pub status: String, + /// Present on `booked` — the instant the appointment actually + /// starts, which on an idempotent retry is the *existing* event's + /// start and not necessarily the one that was asked for. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl Client { + /// `POST /api/voice/booking/slots` — when is this business free? + /// + /// Writes as well as reads: every time it returns is held for + /// `source_id` for a couple of minutes, so a second caller is not + /// offered it while this one is still deciding. Re-offering the same + /// call refreshes its own holds rather than colliding with them. + pub async fn booking_slots( + &self, + request: &BookingSlotsRequest, + ) -> Result { + self.post_json::("/api/voice/booking/slots", request) + .await + } + + /// `POST /api/voice/booking/book` — put the caller in at this time. + pub async fn booking_book(&self, request: &BookingBookRequest) -> Result { + self.post_json::("/api/voice/booking/book", request) + .await + } +} + // ---- Anonymous install heartbeat ------------------------------------------ // // A first-run / per-launch ping the desktop daemon fires *before* (and @@ -2188,12 +2385,98 @@ mod tests { let cursored = serde_json::to_string(&VoiceFlowsQuery { after: Some("flow_abc".into()), limit: Some(100), + schema_versions: None, }) .unwrap(); assert!(cursored.contains("\"after\":\"flow_abc\""), "{cursored}"); assert!(cursored.contains("\"limit\":100"), "{cursored}"); } + #[test] + fn flows_query_sends_schema_versions_under_the_servers_name() { + // The struct is camelCase; this parameter is not. A silently + // camelCased key is ignored by the server, which reads exactly + // like an account with no flows in that version — so pin it. + let query = serde_json::to_string(&VoiceFlowsQuery { + schema_versions: Some("1,2".into()), + ..Default::default() + }) + .unwrap(); + assert_eq!(query, r#"{"schema_versions":"1,2"}"#); + } + + // ---- Booking ---- + + #[test] + fn booking_slots_request_uses_the_routes_snake_case_wire() { + // Unlike the sync resources above, these routes speak snake_case. + // A camelCased body is rejected as a validation error mid-call, + // which the flow can only render as "unavailable". + let body = serde_json::to_string(&BookingSlotsRequest { + source_id: "call_1".into(), + duration_mins: 30, + buffer_mins: 10, + lead_mins: 120, + horizon_days: 14, + schedule: BookingSchedule { + tue: vec![BookingTimeRange { + open: "09:00".into(), + close: "17:00".into(), + }], + ..Default::default() + }, + timezone: "Pacific/Auckland".into(), + exceptions: Vec::new(), + limit: 3, + }) + .unwrap(); + assert!(body.contains(r#""source_id":"call_1""#), "{body}"); + assert!(body.contains(r#""duration_mins":30"#), "{body}"); + assert!(body.contains(r#""timezone":"Pacific/Auckland""#), "{body}"); + // Days with no hours, and an empty exception list, stay off the + // wire entirely rather than shipping empty arrays. + assert!(!body.contains("\"mon\""), "{body}"); + assert!(!body.contains("exceptions"), "{body}"); + } + + #[test] + fn booking_slots_response_parses_both_answers() { + let offered: BookingSlotsResponse = serde_json::from_str( + r#"{"slots":[{"start":"2026-08-11T21:00:00Z","end":"2026-08-11T21:30:00Z"}],"timezone":"Pacific/Auckland"}"#, + ) + .unwrap(); + assert_eq!(offered.slots.len(), 1); + assert_eq!(offered.timezone, "Pacific/Auckland"); + assert!(offered.status.is_none()); + + // The calendar could not be read. Not an error to the caller of + // this crate — the flow has an exit for it. + let down: BookingSlotsResponse = + serde_json::from_str(r#"{"status":"unavailable","reason":"not_connected"}"#).unwrap(); + assert!(down.slots.is_empty()); + assert_eq!(down.status.as_deref(), Some("unavailable")); + assert_eq!(down.reason.as_deref(), Some("not_connected")); + } + + #[test] + fn booking_book_response_parses_every_outcome() { + let booked: BookingBookResponse = + serde_json::from_str(r#"{"status":"booked","start":"2026-08-11T21:00:00Z"}"#).unwrap(); + assert_eq!(booked.status, "booked"); + assert_eq!(booked.start.as_deref(), Some("2026-08-11T21:00:00Z")); + + let taken: BookingBookResponse = + serde_json::from_str(r#"{"status":"slot_taken"}"#).unwrap(); + assert_eq!(taken.status, "slot_taken"); + assert!(taken.start.is_none()); + + // A status this build has never heard of still parses: failing + // here would drop a live call over an unknown string. + let future: BookingBookResponse = + serde_json::from_str(r#"{"status":"needs_deposit"}"#).unwrap(); + assert_eq!(future.status, "needs_deposit"); + } + #[test] fn flows_page_parses_platform_shape() { let raw = r#"{