From b5bed28e9a5acf1a928b007bbc57e2810f1e43ea Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 14:20:19 -0400 Subject: [PATCH 1/2] chore(contract): sync flipcash2 protos to e1f4116c StartChatRequest gains a required idempotency_key (field 9), backed by a new IdempotencyKey message in chat/v1/model.proto. Nothing existing changed shape and no enum was renumbered. Upstream also carries a CLAUDE.md edit (#101) that touches no contract file. --- .../chat_v1_chat_service.pb.swift | 20 +++++++- .../chat_v1_model.pb.swift | 51 +++++++++++++++++++ flipcash2.lock | 4 +- proto/chat/v1/chat_service.proto | 5 ++ proto/chat/v1/model.proto | 15 ++++++ 5 files changed, 92 insertions(+), 3 deletions(-) diff --git a/Sources/Flipcash2ClientProtocol/chat_v1_chat_service.pb.swift b/Sources/Flipcash2ClientProtocol/chat_v1_chat_service.pb.swift index 3a09fd8..fd5d508 100644 --- a/Sources/Flipcash2ClientProtocol/chat_v1_chat_service.pb.swift +++ b/Sources/Flipcash2ClientProtocol/chat_v1_chat_service.pb.swift @@ -367,6 +367,18 @@ public struct Flipcash_Chat_V1_StartChatRequest: Sendable { set {parameters = .group(newValue)} } + /// Key for retrying this request safely. A retry with the same key from the + /// same caller returns the chat the first attempt created, with result OK, + /// even if the parameters differ. + public var idempotencyKey: Flipcash_Chat_V1_IdempotencyKey { + get {return _idempotencyKey ?? Flipcash_Chat_V1_IdempotencyKey()} + set {_idempotencyKey = newValue} + } + /// Returns true if `idempotencyKey` has been explicitly set. + public var hasIdempotencyKey: Bool {return self._idempotencyKey != nil} + /// Clears the value of `idempotencyKey`. Subsequent reads from it will return its default value. + public mutating func clearIdempotencyKey() {self._idempotencyKey = nil} + public var auth: Flipcash_Common_V1_Auth { get {return _auth ?? Flipcash_Common_V1_Auth()} set {_auth = newValue} @@ -427,6 +439,7 @@ public struct Flipcash_Chat_V1_StartChatRequest: Sendable { public init() {} + fileprivate var _idempotencyKey: Flipcash_Chat_V1_IdempotencyKey? = nil fileprivate var _auth: Flipcash_Common_V1_Auth? = nil } @@ -967,7 +980,7 @@ extension Flipcash_Chat_V1_GetGroupChatFeedResponse.Result: SwiftProtobuf._Proto extension Flipcash_Chat_V1_StartChatRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".StartChatRequest" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}group\0\u{2}\u{9}auth\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}group\0\u{4}\u{8}idempotency_key\0\u{1}auth\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -988,6 +1001,7 @@ extension Flipcash_Chat_V1_StartChatRequest: SwiftProtobuf.Message, SwiftProtobu self.parameters = .group(v) } }() + case 9: try { try decoder.decodeSingularMessageField(value: &self._idempotencyKey) }() case 10: try { try decoder.decodeSingularMessageField(value: &self._auth) }() default: break } @@ -1002,6 +1016,9 @@ extension Flipcash_Chat_V1_StartChatRequest: SwiftProtobuf.Message, SwiftProtobu try { if case .group(let v)? = self.parameters { try visitor.visitSingularMessageField(value: v, fieldNumber: 1) } }() + try { if let v = self._idempotencyKey { + try visitor.visitSingularMessageField(value: v, fieldNumber: 9) + } }() try { if let v = self._auth { try visitor.visitSingularMessageField(value: v, fieldNumber: 10) } }() @@ -1010,6 +1027,7 @@ extension Flipcash_Chat_V1_StartChatRequest: SwiftProtobuf.Message, SwiftProtobu public static func ==(lhs: Flipcash_Chat_V1_StartChatRequest, rhs: Flipcash_Chat_V1_StartChatRequest) -> Bool { if lhs.parameters != rhs.parameters {return false} + if lhs._idempotencyKey != rhs._idempotencyKey {return false} if lhs._auth != rhs._auth {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true diff --git a/Sources/Flipcash2ClientProtocol/chat_v1_model.pb.swift b/Sources/Flipcash2ClientProtocol/chat_v1_model.pb.swift index 81abb7d..2445f1e 100644 --- a/Sources/Flipcash2ClientProtocol/chat_v1_model.pb.swift +++ b/Sources/Flipcash2ClientProtocol/chat_v1_model.pb.swift @@ -8,6 +8,7 @@ // For information on using the generated types, please see the documentation: // https://github.com/apple/swift-protobuf/ +import Foundation import SwiftProtobuf // If the compiler emits an error on this type, it is because this file @@ -610,6 +611,26 @@ public struct Flipcash_Chat_V1_RosterUpdateBatch: Sendable { public init() {} } +/// IdempotencyKey lets a client safely retry a request that creates something. +/// The server derives the created object's identity from the caller and this +/// key, so a retry with the same key returns the original result instead of +/// creating a duplicate. Which parameters were sent is not part of the identity: +/// a retry with different parameters still returns the original. +/// +/// The key is owned by the client and is typically a randomly generated UUID. +/// It is never exposed as the object's identity. +public struct Flipcash_Chat_V1_IdempotencyKey: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var value: Data = Data() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + // MARK: - Code below here is support for the SwiftProtobuf runtime. fileprivate let _protobuf_package = "flipcash.chat.v1" @@ -1411,3 +1432,33 @@ extension Flipcash_Chat_V1_RosterUpdateBatch: SwiftProtobuf.Message, SwiftProtob return true } } + +extension Flipcash_Chat_V1_IdempotencyKey: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".IdempotencyKey" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}value\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularBytesField(value: &self.value) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.value.isEmpty { + try visitor.visitSingularBytesField(value: self.value, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Flipcash_Chat_V1_IdempotencyKey, rhs: Flipcash_Chat_V1_IdempotencyKey) -> Bool { + if lhs.value != rhs.value {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/flipcash2.lock b/flipcash2.lock index 18f30e8..d58056d 100644 --- a/flipcash2.lock +++ b/flipcash2.lock @@ -1,4 +1,4 @@ # Pinned upstream contract. Regenerate with scripts/sync-protos.sh . upstream: code-payments/flipcash2-protobuf-api -commit: 89b444bc4ae0d099dbaa90cc8b1b301398c94990 -subject: Add RPCs to start, join and leave group chats (#100) +commit: e1f4116c499718401d002cf79ce64229149ee297 +subject: Add idempotency key to StartChat RPC request (#102) diff --git a/proto/chat/v1/chat_service.proto b/proto/chat/v1/chat_service.proto index 220c782..69dab53 100644 --- a/proto/chat/v1/chat_service.proto +++ b/proto/chat/v1/chat_service.proto @@ -214,6 +214,11 @@ message StartChatRequest { Rules rules = 3; } + // Key for retrying this request safely. A retry with the same key from the + // same caller returns the chat the first attempt created, with result OK, + // even if the parameters differ. + IdempotencyKey idempotency_key = 9 [(validate.rules).message.required = true]; + common.v1.Auth auth = 10 [(validate.rules).message.required = true]; } diff --git a/proto/chat/v1/model.proto b/proto/chat/v1/model.proto index 189d0e0..b8c4068 100644 --- a/proto/chat/v1/model.proto +++ b/proto/chat/v1/model.proto @@ -259,3 +259,18 @@ message RosterUpdateBatch { max_items: 100 }]; } + +// IdempotencyKey lets a client safely retry a request that creates something. +// The server derives the created object's identity from the caller and this +// key, so a retry with the same key returns the original result instead of +// creating a duplicate. Which parameters were sent is not part of the identity: +// a retry with different parameters still returns the original. +// +// The key is owned by the client and is typically a randomly generated UUID. +// It is never exposed as the object's identity. +message IdempotencyKey { + bytes value = 1 [(validate.rules).bytes = { + min_len: 16 + max_len: 16 + }]; +} From 7b67a7f9e11887fe9d112ed7bca03ba78138e1f2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 16 Sep 2026 14:23:26 -0400 Subject: [PATCH 2/2] docs(changelog): add the 0.8.0 entry The required idempotency_key is the part a consumer has to act on: the contract is additive, but 0.7.0 cannot set the field, and a key minted per retry attempt passes validation while defeating the point of it. Also says group chat is still in flux, so the minor bump is not read as the surface having settled. --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c076fe5..e7d25b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,49 @@ called out explicitly even when nothing else did. release notes, so a version with no entry here does not release. Write the entry in the same PR that syncs the contract, while the diff is still in front of you. +## 0.8.0 + +Synced to [`flipcash2-protobuf-api@e1f4116c`](https://github.com/code-payments/flipcash2-protobuf-api/commit/e1f4116c499718401d002cf79ce64229149ee297), +picking up [#102](https://github.com/code-payments/flipcash2-protobuf-api/pull/102). + +`StartChat` gains a required idempotency key, carried by a new `chat.v1.IdempotencyKey` message. +Twenty added lines across two files, nothing removed and nothing renumbered — but this is not a free +upgrade, because the new field is required and 0.7.0 has no way to set it. + +Group chat is still in flux. `StartChat` itself only arrived one version ago, in 0.7.0, and the +surface is still being iterated on, so expect it to keep moving. The bump to 0.8.0 tracks the pinned +contract advancing, not the feature settling — if you are integrating group chat, plan on taking +further versions rather than pinning this one and walking away. + +### Added + +- `chat.v1.IdempotencyKey`, a single `bytes value` (field 1) constrained to exactly 16 bytes: + `min_len` and `max_len` are both 16, so a shorter or longer value fails validation rather than + being padded or truncated. The key is minted and owned by the client, typically a random UUID, and + is never exposed as the created chat's identity. + +- `idempotency_key` on `chat.v1.StartChatRequest`, field 9, + `[(validate.rules).message.required = true]`. It sits between the `parameters` oneof (field 1) and + `auth` (field 10), both of which keep their numbers. + +### Upgrading + +Every `StartChat` call site has to set the key, and where it is minted decides whether the field does +anything. The server derives the created chat's identity from the caller and the key, so a retry +carrying the same key returns the chat the first attempt created, with result `OK`, instead of +creating a second one. The parameters are not part of that identity: a retry with a different title, +picture or rules still returns the original chat, unchanged. So the key is only worth something if it +survives a retry — mint it where the user's intent to create a chat begins and carry it down through +the retry path. A key generated inside the call, fresh on each attempt, passes validation and buys +nothing: the duplicate chat it was meant to prevent is exactly what you get. + +### Unchanged + +Nothing was renumbered. `StartChatResponse.Result` keeps all six of its cases in place +(`OK`, `DENIED`, `TITLE_MODERATED`, `PICTURE_BLOB_NOT_ACCEPTED`, `INVALID_RULES`, +`RULES_NOT_SATISFIED`), so a positional `rawValue` mapping over it does not shift. No existing field +changed number or type, and no service, RPC, message or enum case was removed. + ## 0.7.0 Synced to [`flipcash2-protobuf-api@89b444bc`](https://github.com/code-payments/flipcash2-protobuf-api/commit/89b444bc4ae0d099dbaa90cc8b1b301398c94990),