@@ -68,6 +68,36 @@ pub enum IntentKind {
6868 AppDataUpdate = 12 ,
6969}
7070
71+ impl IntentKind {
72+ /// Every kind this build knows how to deserialize. Production
73+ /// queries pass this as the `allowed_kinds` filter so rows written
74+ /// by a NEWER build (which may use discriminants this build has no
75+ /// variant for) are excluded in SQL instead of poisoning the whole
76+ /// `load()` — `FromSql` errors on unknown discriminants, and one
77+ /// such row would otherwise wedge every intent query for the group
78+ /// after an app downgrade. Unknown-kind rows stay untouched in the
79+ /// table and resume processing when the app is upgraded again.
80+ ///
81+ /// Keep in sync with the enum — the `intent_kind_all_is_exhaustive`
82+ /// test enforces it.
83+ pub fn all ( ) -> Vec < IntentKind > {
84+ vec ! [
85+ IntentKind :: SendMessage ,
86+ IntentKind :: KeyUpdate ,
87+ IntentKind :: MetadataUpdate ,
88+ IntentKind :: UpdateGroupMembership ,
89+ IntentKind :: UpdateAdminList ,
90+ IntentKind :: UpdatePermission ,
91+ IntentKind :: ReaddInstallations ,
92+ IntentKind :: ProposeMemberUpdate ,
93+ IntentKind :: ProposeGroupContextExtensions ,
94+ IntentKind :: CommitPendingProposals ,
95+ IntentKind :: BootstrapMigration ,
96+ IntentKind :: AppDataUpdate ,
97+ ]
98+ }
99+ }
100+
71101impl std:: fmt:: Display for IntentKind {
72102 fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
73103 let description = match self {
@@ -746,6 +776,88 @@ pub(crate) mod tests {
746776 . unwrap ( )
747777 }
748778
779+ /// `IntentKind::all()` is the downgrade-tolerance allowlist — if a
780+ /// variant is added to the enum but not the list, production
781+ /// queries silently stop seeing intents of that kind. Iterating
782+ /// discriminants 1..=max pins the exhaustiveness.
783+ #[ xmtp_common:: test]
784+ fn intent_kind_all_is_exhaustive ( ) {
785+ let all = IntentKind :: all ( ) ;
786+ let max = all. iter ( ) . map ( |k| * k as i32 ) . max ( ) . unwrap ( ) ;
787+ assert_eq ! (
788+ all. len( ) as i32 ,
789+ max,
790+ "IntentKind::all() must contain every discriminant 1..={max} exactly once"
791+ ) ;
792+ for expected in 1 ..=max {
793+ assert ! (
794+ all. iter( ) . any( |k| * k as i32 == expected) ,
795+ "IntentKind::all() is missing discriminant {expected}"
796+ ) ;
797+ }
798+ }
799+
800+ /// Downgrade simulation: a row whose `kind` discriminant this build
801+ /// doesn't know (written by a future version) must not poison
802+ /// kind-filtered queries. Unfiltered queries still error — pinned
803+ /// here so a future change to that behavior is a conscious one.
804+ #[ xmtp_common:: test]
805+ fn unknown_kind_row_is_excluded_by_kind_filter ( ) {
806+ let group_id = GroupId :: generate ( ) ;
807+
808+ with_connection ( |conn| {
809+ insert_group ( conn, group_id) ;
810+
811+ // A known-kind intent this build must keep seeing.
812+ NewGroupIntent :: new_test (
813+ IntentKind :: SendMessage ,
814+ group_id,
815+ rand_vec :: < 24 > ( ) ,
816+ IntentState :: ToPublish ,
817+ )
818+ . store ( conn)
819+ . unwrap ( ) ;
820+
821+ // A future-kind row (discriminant beyond every known
822+ // variant), inserted raw — exactly what a newer build
823+ // leaves behind before an app downgrade.
824+ let future_kind = IntentKind :: all ( ) . len ( ) as i32 + 1 ;
825+ conn. raw_query ( |raw_conn| {
826+ diesel:: insert_into ( dsl:: group_intents)
827+ . values ( (
828+ dsl:: kind. eq ( future_kind) ,
829+ dsl:: group_id. eq ( group_id) ,
830+ dsl:: data. eq ( rand_vec :: < 24 > ( ) ) ,
831+ dsl:: state. eq ( IntentState :: ToPublish ) ,
832+ dsl:: publish_attempts. eq ( 0 ) ,
833+ dsl:: should_push. eq ( false ) ,
834+ ) )
835+ . execute ( raw_conn)
836+ } )
837+ . unwrap ( ) ;
838+
839+ // Kind-filtered (the production shape): unknown row is
840+ // excluded in SQL, the known intent still comes back.
841+ let intents = conn
842+ . find_group_intents (
843+ group_id,
844+ Some ( vec ! [ IntentState :: ToPublish ] ) ,
845+ Some ( IntentKind :: all ( ) ) ,
846+ )
847+ . unwrap ( ) ;
848+ assert_eq ! ( intents. len( ) , 1 ) ;
849+ assert_eq ! ( intents[ 0 ] . kind, IntentKind :: SendMessage ) ;
850+
851+ // Unfiltered: the unknown discriminant fails row
852+ // deserialization and poisons the whole query.
853+ assert ! (
854+ conn. find_group_intents( group_id, Some ( vec![ IntentState :: ToPublish ] ) , None )
855+ . is_err( ) ,
856+ "unfiltered query should surface the FromSql error for unknown kinds"
857+ ) ;
858+ } )
859+ }
860+
749861 #[ xmtp_common:: test]
750862 fn test_store_and_fetch ( ) {
751863 let group_id = GroupId :: generate ( ) ;
0 commit comments