aion-proto 0.29.0

Shared gRPC and serde wire contracts for Aion servers, clients, and workers.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
//! Workflow-management serde/prost wire types.

use crate::convert::{ProtoPayload, ProtoRunId, ProtoWorkflowId, WireEnvelope};
use crate::error::ProtoWireError;

/// Proto representation of `StartWorkflowRequest`.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoStartWorkflowRequest {
    /// Namespace that scopes the operation.
    #[prost(string, tag = "1")]
    pub namespace: String,
    /// Workflow type name registered with the engine.
    #[prost(string, tag = "2")]
    pub workflow_type: String,
    /// Workflow start input payload.
    #[prost(message, optional, tag = "3")]
    pub input: Option<ProtoPayload>,
    /// R-4 steered-start routing key. When set, the start is steered to
    /// `shard_for(routing_key)`'s owner (forwarded there when this node is not the
    /// owner). `None`/empty keeps the unsteered R-1 remint behaviour.
    #[prost(string, optional, tag = "4")]
    pub routing_key: Option<String>,
    /// Optional task queue this workflow defaults its activities to (the
    /// namespace × `task_queue` targeting story). When set, the server records it
    /// durably on the start so it survives replay/failover. `None`/empty keeps
    /// the namespace's default queue.
    #[prost(string, optional, tag = "5")]
    pub task_queue: Option<String>,
    /// Optional operator-facing display name for the workflow this start
    /// creates (#211). A LABEL over the UUID identity, never an address:
    /// nothing resolves a workflow by name. When set, the server records it
    /// durably (as the `aion.display_name` search attribute) in the same atomic
    /// append as the start. `None` starts it unnamed; a present but blank
    /// (empty or whitespace-only) value is REFUSED with `invalid_input` rather
    /// than reinterpreted as unnamed, so pass `None` to mean "no name" — never
    /// `Some("")`. Unlike `routing_key` and `task_queue`, empty here is not
    /// "not selected".
    ///
    /// The recorded attribute carries no run id, so it reads back per WORKFLOW
    /// (folded over the whole history, last write wins) rather than per run.
    #[prost(string, optional, tag = "6")]
    pub display_name: Option<String>,
}

/// Proto representation of `StartWorkflowResponse`.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoStartWorkflowResponse {
    /// Assigned workflow identifier.
    #[prost(message, optional, tag = "1")]
    pub workflow_id: Option<ProtoWorkflowId>,
    /// Assigned concrete run identifier.
    #[prost(message, optional, tag = "2")]
    pub run_id: Option<ProtoRunId>,
}

/// Proto representation of `SignalRequest`.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoSignalRequest {
    /// Namespace that scopes the operation.
    #[prost(string, tag = "1")]
    pub namespace: String,
    /// Target workflow identifier.
    #[prost(message, optional, tag = "2")]
    pub workflow_id: Option<ProtoWorkflowId>,
    /// Target run identifier.
    #[prost(message, optional, tag = "3")]
    pub run_id: Option<ProtoRunId>,
    /// Signal name registered by workflow code.
    #[prost(string, tag = "4")]
    pub signal_name: String,
    /// Signal payload.
    #[prost(message, optional, tag = "5")]
    pub payload: Option<ProtoPayload>,
}

/// Proto representation of `SignalResponse`.
#[derive(Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoSignalResponse {}

/// Proto representation of `QueryRequest`.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoQueryRequest {
    /// Namespace that scopes the operation.
    #[prost(string, tag = "1")]
    pub namespace: String,
    /// Target workflow identifier.
    #[prost(message, optional, tag = "2")]
    pub workflow_id: Option<ProtoWorkflowId>,
    /// Target run identifier.
    #[prost(message, optional, tag = "3")]
    pub run_id: Option<ProtoRunId>,
    /// Query name registered by workflow code.
    #[prost(string, tag = "4")]
    pub query_name: String,
    /// Caller-supplied arguments handed to the workflow's query handler.
    ///
    /// Absent means "no arguments": the server materializes the JSON `null`
    /// document so a handler always receives one well-formed input. Arguments
    /// are read-only handler inputs and are never recorded in history.
    #[prost(message, optional, tag = "5")]
    pub arguments: Option<ProtoPayload>,
}

/// Proto representation of `QueryResponse`.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoQueryResponse {
    /// Query result or typed wire error.
    #[prost(oneof = "proto_query_response::Outcome", tags = "1, 2")]
    pub outcome: Option<proto_query_response::Outcome>,
}

/// Types nested under [`ProtoQueryResponse`].
pub mod proto_query_response {
    /// Proto oneof for successful query payloads and typed failures.
    #[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Oneof)]
    pub enum Outcome {
        /// Query result payload.
        #[prost(message, tag = "1")]
        Result(super::ProtoPayload),
        /// Typed query error.
        #[prost(message, tag = "2")]
        Error(super::ProtoWireError),
    }
}

/// Proto representation of `CancelRequest`.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoCancelRequest {
    /// Namespace that scopes the operation.
    #[prost(string, tag = "1")]
    pub namespace: String,
    /// Target workflow identifier.
    #[prost(message, optional, tag = "2")]
    pub workflow_id: Option<ProtoWorkflowId>,
    /// Target run identifier.
    #[prost(message, optional, tag = "3")]
    pub run_id: Option<ProtoRunId>,
    /// Human-readable cancellation reason.
    #[prost(string, tag = "4")]
    pub reason: String,
}

/// Proto representation of `CancelResponse`.
#[derive(Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoCancelResponse {}

/// Proto representation of `RetireWorkloopRequest`.
///
/// Mirrors [`ProtoCancelRequest`] without a `run_id` and without any body
/// selector. No run id, because retirement targets the LOOP and always acts on
/// its current generation — a loop's earlier generations are already
/// terminated by their own iteration closes. No body selector, because whether
/// the declared `retire` body runs is decided by the deployed document, not by
/// the caller: an argument that could skip a declared cleanup is how a lease is
/// lost.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoRetireWorkloopRequest {
    /// Namespace that scopes the operation.
    #[prost(string, tag = "1")]
    pub namespace: String,
    /// Target workloop identifier.
    #[prost(message, optional, tag = "2")]
    pub workflow_id: Option<ProtoWorkflowId>,
    /// The operator's stated reason, recorded verbatim on `LoopRetired`.
    #[prost(string, tag = "3")]
    pub reason: String,
}

/// Proto representation of `RetireWorkloopResponse`: the reason the
/// retirement recorded, so a caller that supplied none learns what the loop's
/// history now says.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoRetireWorkloopResponse {
    /// The reason recorded on `LoopRetired`.
    #[prost(string, tag = "1")]
    pub reason: String,
}

/// Proto representation of `ReopenRequest`.
///
/// Mirrors [`ProtoCancelRequest`] without a `reason`: the reopen carries only a
/// target. An absent `run_id` means the latest run.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoReopenRequest {
    /// Namespace that scopes the operation.
    #[prost(string, tag = "1")]
    pub namespace: String,
    /// Target workflow identifier.
    #[prost(message, optional, tag = "2")]
    pub workflow_id: Option<ProtoWorkflowId>,
    /// Target run identifier (absent means the latest run).
    #[prost(message, optional, tag = "3")]
    pub run_id: Option<ProtoRunId>,
}

/// Proto representation of `ReopenResponse`.
///
/// Unlike [`ProtoCancelResponse`] (an empty ack) this returns the reopened run
/// id and its projected status (Running) so the caller learns the run is live
/// again.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoReopenResponse {
    /// The reopened concrete run identifier.
    #[prost(message, optional, tag = "1")]
    pub run_id: Option<ProtoRunId>,
    /// The projected workflow status after the reopen (Running).
    #[prost(enumeration = "crate::convert::ProtoWorkflowStatus", tag = "2")]
    pub status: i32,
}

/// Proto representation of `PauseRequest` (#204).
///
/// Mirrors [`ProtoCancelRequest`]: a target plus an optional reason. An absent
/// `run_id` means the latest run.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoPauseRequest {
    /// Namespace that scopes the operation.
    #[prost(string, tag = "1")]
    pub namespace: String,
    /// Target workflow identifier.
    #[prost(message, optional, tag = "2")]
    pub workflow_id: Option<ProtoWorkflowId>,
    /// Target run identifier (absent means the latest run).
    #[prost(message, optional, tag = "3")]
    pub run_id: Option<ProtoRunId>,
    /// Optional operator-supplied pause reason.
    #[prost(string, tag = "4")]
    pub reason: String,
}

/// Proto representation of `PauseResponse` (#204): the paused run and its status.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoPauseResponse {
    /// The paused concrete run identifier.
    #[prost(message, optional, tag = "1")]
    pub run_id: Option<ProtoRunId>,
    /// The projected workflow status after the pause (Paused).
    #[prost(enumeration = "crate::convert::ProtoWorkflowStatus", tag = "2")]
    pub status: i32,
}

/// Proto representation of `ResumeRequest` (#204).
///
/// Mirrors [`ProtoReopenRequest`]: only a target. An absent `run_id` means the
/// latest run.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoResumeRequest {
    /// Namespace that scopes the operation.
    #[prost(string, tag = "1")]
    pub namespace: String,
    /// Target workflow identifier.
    #[prost(message, optional, tag = "2")]
    pub workflow_id: Option<ProtoWorkflowId>,
    /// Target run identifier (absent means the latest run).
    #[prost(message, optional, tag = "3")]
    pub run_id: Option<ProtoRunId>,
}

/// Proto representation of `ResumeResponse` (#204): the resumed run and status.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoResumeResponse {
    /// The resumed concrete run identifier.
    #[prost(message, optional, tag = "1")]
    pub run_id: Option<ProtoRunId>,
    /// The projected workflow status after the resume (Running).
    #[prost(enumeration = "crate::convert::ProtoWorkflowStatus", tag = "2")]
    pub status: i32,
}

/// Proto representation of `RenameRequest` (#211).
///
/// Mirrors [`ProtoPauseRequest`]'s shape: a target plus the operator's payload
/// (here the new display name). An absent `run_id` means the latest run. The
/// name is a LABEL over the UUID identity, never an address — this request
/// SETS a name on an id-addressed run; nothing resolves a workflow by name.
///
/// Both facts hold at once, and they are easy to confuse: you ADDRESS a run
/// (workflow id plus run id, so the server can refuse a rename it cannot append
/// safely), but what gets recorded is a WORKFLOW-level attribute with no run id
/// in it. The name therefore reads back for every run of that workflow, which
/// is exactly why the engine refuses to rename a continue-as-new predecessor:
/// its name would land on the successor that now owns the history head.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoRenameRequest {
    /// Namespace that scopes the operation.
    #[prost(string, tag = "1")]
    pub namespace: String,
    /// Target workflow identifier.
    #[prost(message, optional, tag = "2")]
    pub workflow_id: Option<ProtoWorkflowId>,
    /// Target run identifier (absent means the latest run).
    #[prost(message, optional, tag = "3")]
    pub run_id: Option<ProtoRunId>,
    /// The new display name. Trimmed by the server; must be non-empty after
    /// trimming.
    #[prost(string, tag = "4")]
    pub display_name: String,
}

/// Proto representation of `RenameResponse` (#211): the renamed run and the
/// display name as recorded (trimmed).
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoRenameResponse {
    /// The renamed concrete run identifier.
    #[prost(message, optional, tag = "1")]
    pub run_id: Option<ProtoRunId>,
    /// The display name as recorded (trimmed).
    #[prost(string, tag = "2")]
    pub display_name: String,
}

/// Proto representation of `ListWorkflowsRequest`.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoListWorkflowsRequest {
    /// Namespace that scopes the operation — what the caller is authorized
    /// against. The envelope's own namespace must equal it.
    #[prost(string, tag = "1")]
    pub namespace: String,
    /// Serde-encoded [`aion_core::WorkflowListRequest`] envelope: the ONE
    /// list contract (filter, required sort, opaque cursor, limit).
    #[prost(message, optional, tag = "2")]
    pub request: Option<WireEnvelope>,
}

/// Proto representation of `ListWorkflowsResponse`.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoListWorkflowsResponse {
    /// Serde-encoded [`aion_core::WorkflowListPage`] envelope: the page's
    /// items, the cursor for the next page, and the filtered total.
    #[prost(message, optional, tag = "1")]
    pub page: Option<WireEnvelope>,
}

/// Proto representation of `DescribeWorkflowRequest`.
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, prost::Message)]
pub struct ProtoDescribeWorkflowRequest {
    /// Namespace that scopes the operation.
    #[prost(string, tag = "1")]
    pub namespace: String,
    /// Target workflow identifier.
    #[prost(message, optional, tag = "2")]
    pub workflow_id: Option<ProtoWorkflowId>,
    /// Target run identifier.
    #[prost(message, optional, tag = "3")]
    pub run_id: Option<ProtoRunId>,
    /// Whether event history should be included in the response.
    #[prost(bool, tag = "4")]
    pub include_history: bool,
}

#[cfg(test)]
mod tests {
    use aion_core::{
        SortDirection, WorkflowListFilter, WorkflowListPage, WorkflowListRequest, WorkflowSort,
        WorkflowSortField, WorkflowSummary,
    };
    use chrono::{DateTime, Utc};
    use prost::Message;
    use serde::de::DeserializeOwned;
    use serde_json::json;

    use super::{
        ProtoListWorkflowsRequest, ProtoListWorkflowsResponse, ProtoQueryRequest,
        ProtoQueryResponse, ProtoReopenRequest, ProtoReopenResponse, ProtoStartWorkflowRequest,
        ProtoStartWorkflowResponse, proto_query_response,
    };
    use crate::convert::{
        ProtoPayload, ProtoRunId, ProtoWorkflowId, decode_core_value, encode_core_value,
    };
    use crate::error::{ProtoWireError, WireError};

    fn workflow_id() -> aion_core::WorkflowId {
        aion_core::WorkflowId::new(uuid::Uuid::nil())
    }

    fn run_id() -> aion_core::RunId {
        aion_core::RunId::new(uuid::Uuid::nil())
    }

    fn payload(label: &str) -> Result<ProtoPayload, aion_core::PayloadError> {
        Ok(ProtoPayload::from(aion_core::Payload::from_json(
            &json!({ "label": label }),
        )?))
    }

    fn recorded_at() -> Result<DateTime<Utc>, chrono::ParseError> {
        Ok(DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")?.with_timezone(&Utc))
    }

    fn assert_json_round_trip<T>(value: &T) -> Result<(), serde_json::Error>
    where
        T: Clone + PartialEq + serde::Serialize + DeserializeOwned,
    {
        let encoded = serde_json::to_string(value)?;
        let decoded = serde_json::from_str::<T>(&encoded)?;
        assert!(decoded == *value);
        Ok(())
    }

    fn assert_proto_round_trip<T>(value: &T) -> Result<(), Box<dyn std::error::Error>>
    where
        T: Clone + PartialEq + Message + Default,
    {
        let mut bytes = Vec::new();
        value.encode(&mut bytes)?;
        let decoded = T::decode(bytes.as_slice())?;
        assert!(decoded == *value);
        Ok(())
    }

    #[test]
    fn start_workflow_round_trips_json_and_proto() -> Result<(), Box<dyn std::error::Error>> {
        let request = ProtoStartWorkflowRequest {
            namespace: String::from("tenant-a"),
            workflow_type: String::from("checkout"),
            input: Some(payload("input")?),
            routing_key: Some(String::from("tenant-a/order-1")),
            task_queue: Some(String::from("gpu")),
            display_name: Some(String::from("Order 1 checkout")),
        };
        let response = ProtoStartWorkflowResponse {
            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
            run_id: Some(ProtoRunId::from(run_id())),
        };

        assert_json_round_trip(&request)?;
        assert_proto_round_trip(&request)?;
        assert_json_round_trip(&response)?;
        assert_proto_round_trip(&response)?;
        Ok(())
    }

    #[test]
    fn list_workflows_round_trips_json_and_proto() -> Result<(), Box<dyn std::error::Error>> {
        let list_request = WorkflowListRequest {
            namespace: String::from("tenant-a"),
            filter: WorkflowListFilter {
                workflow_types: vec![String::from("checkout")],
                statuses: vec![aion_core::WorkflowStatus::Running],
                ..WorkflowListFilter::default()
            },
            sort: WorkflowSort {
                field: WorkflowSortField::UpdatedAt,
                direction: SortDirection::Desc,
            },
            cursor: Some(String::from("opaque")),
            limit: 10,
        };
        let page = WorkflowListPage {
            items: vec![WorkflowSummary {
                workflow_id: workflow_id(),
                run_id: run_id(),
                workflow_type: String::from("checkout"),
                status: aion_core::WorkflowStatus::Running,
                started_at: recorded_at()?,
                updated_at: recorded_at()?,
                ended_at: None,
                parent: None,
                failed_step: None,
                failure_reason: None,
                display_name: Some(String::from("Nightly close")),
                kind: None,
                current_worker: None,
                package_version: None,
            }],
            next_cursor: Some(String::from("next")),
            count: 7,
            provenance: None,
        };
        let request_envelope =
            encode_core_value("tenant-a", Some(String::from("r1")), &list_request)?;
        let page_envelope = encode_core_value("tenant-a", None, &page)?;
        let request = ProtoListWorkflowsRequest {
            namespace: String::from("tenant-a"),
            request: Some(request_envelope.clone()),
        };
        let response = ProtoListWorkflowsResponse {
            page: Some(page_envelope.clone()),
        };

        assert_json_round_trip(&request)?;
        assert_proto_round_trip(&request)?;
        assert_json_round_trip(&response)?;
        assert_proto_round_trip(&response)?;
        assert_eq!(
            decode_core_value::<WorkflowListRequest>(&request_envelope)?,
            list_request
        );
        assert_eq!(decode_core_value::<WorkflowListPage>(&page_envelope)?, page);
        Ok(())
    }

    #[test]
    fn query_round_trips_json_and_proto() -> Result<(), Box<dyn std::error::Error>> {
        let request = ProtoQueryRequest {
            namespace: String::from("tenant-a"),
            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
            run_id: Some(ProtoRunId::from(run_id())),
            query_name: String::from("state"),
            arguments: Some(payload("arguments")?),
        };
        // A caller that supplies no arguments is a distinct encoded shape, not
        // an error: the field is optional on the wire and the server supplies
        // the canonical `null` document in its place.
        let no_arguments_request = ProtoQueryRequest {
            arguments: None,
            ..request.clone()
        };
        let result_response = ProtoQueryResponse {
            outcome: Some(proto_query_response::Outcome::Result(payload("result")?)),
        };
        let error_response = ProtoQueryResponse {
            outcome: Some(proto_query_response::Outcome::Error(ProtoWireError::from(
                WireError::unknown_query("state query is not registered"),
            ))),
        };

        assert_json_round_trip(&request)?;
        assert_proto_round_trip(&request)?;
        assert_json_round_trip(&no_arguments_request)?;
        assert_proto_round_trip(&no_arguments_request)?;
        assert_json_round_trip(&result_response)?;
        assert_proto_round_trip(&result_response)?;
        assert_json_round_trip(&error_response)?;
        assert_proto_round_trip(&error_response)?;
        // The two shapes stay distinguishable across a proto round trip: a
        // decoder can tell "no arguments supplied" from any supplied document.
        assert_ne!(request, no_arguments_request);
        Ok(())
    }

    /// #211: an unnamed start is a distinct wire shape from a named one, and
    /// both round-trip across JSON and proto without conflating.
    #[test]
    fn start_workflow_display_name_absent_round_trips() -> Result<(), Box<dyn std::error::Error>> {
        let named = ProtoStartWorkflowRequest {
            namespace: String::from("tenant-a"),
            workflow_type: String::from("checkout"),
            input: Some(payload("input")?),
            routing_key: None,
            task_queue: None,
            display_name: Some(String::from("Order 1 checkout")),
        };
        let unnamed = ProtoStartWorkflowRequest {
            display_name: None,
            ..named.clone()
        };

        assert_json_round_trip(&named)?;
        assert_proto_round_trip(&named)?;
        assert_json_round_trip(&unnamed)?;
        assert_proto_round_trip(&unnamed)?;
        // The two shapes stay distinguishable across the wire: a decoder can
        // tell "no name supplied" from any supplied name.
        assert_ne!(named, unnamed);
        Ok(())
    }

    /// #211: the HAND-WRITTEN `display_name` and the GENERATED stub's
    /// `display_name` are the same wire field.
    ///
    /// Both sides declare tag 6, but "both say 6" is two claims, not agreement.
    /// This encodes with one and decodes with the other, in both directions, so
    /// a tag or wire-type drift between the `.proto` and the hand-written prost
    /// derive is a failure here rather than a field that silently vanishes at a
    /// real transport boundary. Compiled only under `generated`, which is the
    /// only posture in which the stubs exist at all.
    #[cfg(feature = "generated")]
    #[test]
    fn start_workflow_display_name_is_the_same_wire_field_as_the_generated_stub()
    -> Result<(), Box<dyn std::error::Error>> {
        const NAME: &str = "Nightly settlement";
        let hand_written = ProtoStartWorkflowRequest {
            namespace: String::from("tenant-a"),
            workflow_type: String::from("checkout"),
            input: None,
            routing_key: None,
            task_queue: None,
            display_name: Some(String::from(NAME)),
        };

        // Hand-written -> generated.
        let mut bytes = Vec::new();
        hand_written.encode(&mut bytes)?;
        let decoded = crate::generated::StartWorkflowRequest::decode(bytes.as_slice())?;
        assert_eq!(
            decoded.display_name.as_deref(),
            Some(NAME),
            "the generated stub must read the hand-written display_name"
        );

        // Generated -> hand-written.
        let mut bytes = Vec::new();
        decoded.encode(&mut bytes)?;
        let round_tripped = ProtoStartWorkflowRequest::decode(bytes.as_slice())?;
        assert_eq!(round_tripped, hand_written);

        // And the field key itself is pinned: tag 6, length-delimited, is
        // (6 << 3) | 2 = 0x32. An unnamed start emits it nowhere.
        let mut bytes = Vec::new();
        ProtoStartWorkflowRequest {
            namespace: String::new(),
            workflow_type: String::new(),
            input: None,
            routing_key: None,
            task_queue: None,
            display_name: Some(String::from("x")),
        }
        .encode(&mut bytes)?;
        assert_eq!(bytes, vec![0x32, 0x01, b'x']);

        let mut bytes = Vec::new();
        ProtoStartWorkflowRequest {
            namespace: String::new(),
            workflow_type: String::new(),
            input: None,
            routing_key: None,
            task_queue: None,
            display_name: None,
        }
        .encode(&mut bytes)?;
        assert!(
            bytes.is_empty(),
            "an unnamed start must put nothing on the wire, got {bytes:?}"
        );
        Ok(())
    }

    /// #211: the hand-written Rename messages agree with the generated stubs,
    /// in both directions.
    #[cfg(feature = "generated")]
    #[test]
    fn rename_messages_agree_with_the_generated_stubs() -> Result<(), Box<dyn std::error::Error>> {
        let request = super::ProtoRenameRequest {
            namespace: String::from("tenant-a"),
            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
            run_id: Some(ProtoRunId::from(run_id())),
            display_name: String::from("Nightly settlement"),
        };
        let mut bytes = Vec::new();
        request.encode(&mut bytes)?;
        let decoded = crate::generated::RenameRequest::decode(bytes.as_slice())?;
        assert_eq!(decoded.display_name, "Nightly settlement");
        assert_eq!(decoded.namespace, "tenant-a");
        let mut bytes = Vec::new();
        decoded.encode(&mut bytes)?;
        assert_eq!(
            super::ProtoRenameRequest::decode(bytes.as_slice())?,
            request
        );

        let response = super::ProtoRenameResponse {
            run_id: Some(ProtoRunId::from(run_id())),
            display_name: String::from("Nightly settlement"),
        };
        let mut bytes = Vec::new();
        response.encode(&mut bytes)?;
        let decoded = crate::generated::RenameResponse::decode(bytes.as_slice())?;
        assert_eq!(decoded.display_name, "Nightly settlement");
        let mut bytes = Vec::new();
        decoded.encode(&mut bytes)?;
        assert_eq!(
            super::ProtoRenameResponse::decode(bytes.as_slice())?,
            response
        );
        Ok(())
    }

    #[test]
    fn rename_round_trips_json_and_proto() -> Result<(), Box<dyn std::error::Error>> {
        let request = super::ProtoRenameRequest {
            namespace: String::from("tenant-a"),
            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
            run_id: Some(ProtoRunId::from(run_id())),
            display_name: String::from("Nightly settlement"),
        };
        let response = super::ProtoRenameResponse {
            run_id: Some(ProtoRunId::from(run_id())),
            display_name: String::from("Nightly settlement"),
        };

        assert_json_round_trip(&request)?;
        assert_proto_round_trip(&request)?;
        assert_json_round_trip(&response)?;
        assert_proto_round_trip(&response)?;
        Ok(())
    }

    #[test]
    fn reopen_round_trips_json_and_proto() -> Result<(), Box<dyn std::error::Error>> {
        let request = ProtoReopenRequest {
            namespace: String::from("tenant-a"),
            workflow_id: Some(ProtoWorkflowId::from(workflow_id())),
            run_id: Some(ProtoRunId::from(run_id())),
        };
        let response = ProtoReopenResponse {
            run_id: Some(ProtoRunId::from(run_id())),
            status: crate::convert::ProtoWorkflowStatus::Running as i32,
        };

        assert_json_round_trip(&request)?;
        assert_proto_round_trip(&request)?;
        assert_json_round_trip(&response)?;
        assert_proto_round_trip(&response)?;
        Ok(())
    }
}