1use riffdb_proto::v1;
14use riffdb_types::{MAX_IDEMPOTENCY_KEY_BYTES, RequestId, Timestamp};
15
16use super::{GeneratedCommand, GeneratedCommandError};
17use crate::IdempotentCommand;
18
19pub const CONTRACT_LINEAGE: &str = "LegalSpend";
21pub const CONTRACT_VERSION: u64 = 1;
23pub const CREATE_BUDGET_PLAN_HASH: [u8; 32] = [
25 0x49, 0xf2, 0x57, 0x73, 0xdf, 0xaa, 0x1c, 0x3e, 0x16, 0x2f, 0x75, 0x33, 0xa8, 0x17, 0x62, 0x04,
26 0x7e, 0x25, 0x75, 0x0f, 0xf7, 0xb0, 0xab, 0x38, 0xc5, 0x4a, 0x86, 0x88, 0x21, 0x16, 0x96, 0xf1,
27];
28pub const ALLOCATE_BUDGET_PLAN_HASH: [u8; 32] = [
30 0x1f, 0xaa, 0xca, 0xc3, 0x30, 0xda, 0x29, 0xe4, 0xf7, 0x5d, 0x2a, 0xfd, 0xa9, 0x3c, 0xd7, 0x3d,
31 0x3f, 0x8e, 0xfc, 0x53, 0xf2, 0xd8, 0x9c, 0x4a, 0x0e, 0x40, 0x9e, 0xea, 0xa2, 0x33, 0x54, 0xf0,
32];
33const MAX_DECIMAL_COEFFICIENT: i128 = 9_999_999_999_999_999_999_999_999_999;
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub struct Amount(i128);
38
39impl Amount {
40 #[must_use]
42 pub const fn from_minor_units(coefficient: i128) -> Option<Self> {
43 if coefficient >= -MAX_DECIMAL_COEFFICIENT && coefficient <= MAX_DECIMAL_COEFFICIENT {
44 Some(Self(coefficient))
45 } else {
46 None
47 }
48 }
49
50 #[must_use]
52 pub const fn minor_units(self) -> i128 {
53 self.0
54 }
55}
56
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub struct Budget {
60 pub organization_id: [u8; 16],
62 pub fiscal_year: i64,
64 pub approved_amount: Amount,
66 pub allocated_amount: Amount,
68 pub updated_at: Timestamp,
70}
71
72#[derive(Clone, Eq, PartialEq)]
74pub struct CreateBudget {
75 pub idempotency_key: String,
77 pub organization_id: [u8; 16],
79 pub fiscal_year: i64,
81 pub approved_amount: Amount,
83}
84
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub enum CreateBudgetOutcome {
88 BudgetCreated {
90 budget: Budget,
92 },
93 BudgetAlreadyExists {
95 organization_id: [u8; 16],
97 fiscal_year: i64,
99 },
100 InvalidApprovedAmount {
102 minimum: Amount,
104 },
105}
106
107impl GeneratedCommand for CreateBudget {
108 type Outcome = CreateBudgetOutcome;
109
110 fn idempotent_command(&self) -> Result<IdempotentCommand, GeneratedCommandError> {
111 let idempotency_key = checked_idempotency_key(&self.idempotency_key)?;
112 IdempotentCommand::new(
113 "CreateBudget",
114 Some(CONTRACT_VERSION),
115 record(vec![
116 field(1, i64_value(self.fiscal_year)),
117 field(2, decimal_value(self.approved_amount)),
118 field(3, string_value(idempotency_key.to_owned())),
119 field(4, uuid_value(self.organization_id)),
120 ]),
121 )
122 .map_err(Into::into)
123 }
124
125 fn outcome_request(
126 &self,
127 request_id: RequestId,
128 ) -> Result<v1::GetOutcomeRequest, GeneratedCommandError> {
129 Ok(v1::GetOutcomeRequest {
130 request_id: request_id.into_bytes().to_vec(),
131 contract_lineage: CONTRACT_LINEAGE.to_owned(),
132 command_name: "CreateBudget".to_owned(),
133 idempotency_key: checked_idempotency_key(&self.idempotency_key)?.to_owned(),
134 outcome_uri: None,
135 })
136 }
137
138 fn decode_outcome(
139 &self,
140 response: &v1::ExecuteCommandResponse,
141 ) -> Result<Self::Outcome, GeneratedCommandError> {
142 let fields = outcome_fields(response, &CREATE_BUDGET_PLAN_HASH)?;
143 match response.outcome_type.as_str() {
144 "BudgetCreated" => {
145 require_field_ids(fields, &[1])?;
146 Ok(CreateBudgetOutcome::BudgetCreated {
147 budget: decode_budget(record_field(fields, 1)?)?,
148 })
149 }
150 "BudgetAlreadyExists" => {
151 require_field_ids(fields, &[1, 2])?;
152 Ok(CreateBudgetOutcome::BudgetAlreadyExists {
153 fiscal_year: i64_field(fields, 1)?,
154 organization_id: uuid_field(fields, 2)?,
155 })
156 }
157 "InvalidApprovedAmount" => {
158 require_field_ids(fields, &[1])?;
159 Ok(CreateBudgetOutcome::InvalidApprovedAmount {
160 minimum: minimum_field(fields, 1)?,
161 })
162 }
163 _ => Err(GeneratedCommandError::InvalidOutcomeShape),
164 }
165 }
166}
167
168#[derive(Clone, Eq, PartialEq)]
170pub struct AllocateBudget {
171 pub idempotency_key: String,
173 pub organization_id: [u8; 16],
175 pub fiscal_year: i64,
177 pub matter_id: [u8; 16],
179 pub amount: Amount,
181}
182
183#[derive(Clone, Copy, Debug, Eq, PartialEq)]
185pub enum AllocateBudgetOutcome {
186 Allocated {
188 budget: Budget,
190 remaining: Amount,
192 },
193 InvalidAmount {
195 minimum: Amount,
197 },
198 BudgetNotFound {
200 organization_id: [u8; 16],
202 fiscal_year: i64,
204 },
205 InsufficientBudget {
207 approved: Amount,
209 allocated: Amount,
211 requested: Amount,
213 },
214}
215
216impl GeneratedCommand for AllocateBudget {
217 type Outcome = AllocateBudgetOutcome;
218
219 fn idempotent_command(&self) -> Result<IdempotentCommand, GeneratedCommandError> {
220 let idempotency_key = checked_idempotency_key(&self.idempotency_key)?;
221 IdempotentCommand::new(
222 "AllocateBudget",
223 Some(CONTRACT_VERSION),
224 record(vec![
225 field(1, decimal_value(self.amount)),
226 field(2, uuid_value(self.matter_id)),
227 field(3, i64_value(self.fiscal_year)),
228 field(4, string_value(idempotency_key.to_owned())),
229 field(5, uuid_value(self.organization_id)),
230 ]),
231 )
232 .map_err(Into::into)
233 }
234
235 fn outcome_request(
236 &self,
237 request_id: RequestId,
238 ) -> Result<v1::GetOutcomeRequest, GeneratedCommandError> {
239 Ok(v1::GetOutcomeRequest {
240 request_id: request_id.into_bytes().to_vec(),
241 contract_lineage: CONTRACT_LINEAGE.to_owned(),
242 command_name: "AllocateBudget".to_owned(),
243 idempotency_key: checked_idempotency_key(&self.idempotency_key)?.to_owned(),
244 outcome_uri: None,
245 })
246 }
247
248 fn decode_outcome(
249 &self,
250 response: &v1::ExecuteCommandResponse,
251 ) -> Result<Self::Outcome, GeneratedCommandError> {
252 let fields = outcome_fields(response, &ALLOCATE_BUDGET_PLAN_HASH)?;
253 match response.outcome_type.as_str() {
254 "Allocated" => {
255 require_field_ids(fields, &[1, 2])?;
256 Ok(AllocateBudgetOutcome::Allocated {
257 budget: decode_budget(record_field(fields, 1)?)?,
258 remaining: decimal_field(fields, 2)?,
259 })
260 }
261 "InvalidAmount" => {
262 require_field_ids(fields, &[1])?;
263 Ok(AllocateBudgetOutcome::InvalidAmount {
264 minimum: minimum_field(fields, 1)?,
265 })
266 }
267 "BudgetNotFound" => {
268 require_field_ids(fields, &[1, 2])?;
269 Ok(AllocateBudgetOutcome::BudgetNotFound {
270 fiscal_year: i64_field(fields, 1)?,
271 organization_id: uuid_field(fields, 2)?,
272 })
273 }
274 "InsufficientBudget" => {
275 require_field_ids(fields, &[1, 2, 3])?;
276 Ok(AllocateBudgetOutcome::InsufficientBudget {
277 approved: decimal_field(fields, 1)?,
278 allocated: decimal_field(fields, 2)?,
279 requested: decimal_field(fields, 3)?,
280 })
281 }
282 _ => Err(GeneratedCommandError::InvalidOutcomeShape),
283 }
284 }
285}
286
287fn decode_budget(value: &v1::ValueRecord) -> Result<Budget, GeneratedCommandError> {
288 require_field_ids(value, &[1, 2, 3, 4, 5])?;
289 Ok(Budget {
290 updated_at: timestamp_field(value, 1)?,
291 fiscal_year: i64_field(value, 2)?,
292 approved_amount: decimal_field(value, 3)?,
293 organization_id: uuid_field(value, 4)?,
294 allocated_amount: decimal_field(value, 5)?,
295 })
296}
297
298fn checked_idempotency_key(value: &str) -> Result<&str, GeneratedCommandError> {
299 if value.is_empty() || value.len() > MAX_IDEMPOTENCY_KEY_BYTES {
300 return Err(GeneratedCommandError::InvalidInputShape);
301 }
302 Ok(value)
303}
304
305fn outcome_fields<'response>(
306 response: &'response v1::ExecuteCommandResponse,
307 plan_hash: &[u8; 32],
308) -> Result<&'response v1::ValueRecord, GeneratedCommandError> {
309 if response.contract_version != CONTRACT_VERSION
310 || response.plan_hash.as_slice() != plan_hash
311 || !matches!(
312 v1::execute_command_response::CompletionStatus::try_from(response.status),
313 Ok(v1::execute_command_response::CompletionStatus::Committed)
314 | Ok(v1::execute_command_response::CompletionStatus::Replayed)
315 )
316 {
317 return Err(GeneratedCommandError::InvalidOutcomeShape);
318 }
319 let Some(v1::Value {
320 kind: Some(v1::value::Kind::RecordValue(fields)),
321 }) = response.outcome.as_ref()
322 else {
323 return Err(GeneratedCommandError::InvalidOutcomeShape);
324 };
325 Ok(fields)
326}
327
328fn require_field_ids(
329 record: &v1::ValueRecord,
330 expected: &[u32],
331) -> Result<(), GeneratedCommandError> {
332 if record.fields.len() != expected.len()
333 || !record
334 .fields
335 .iter()
336 .zip(expected)
337 .all(|(field, expected)| field.field_id == Some(*expected))
338 {
339 return Err(GeneratedCommandError::InvalidOutcomeShape);
340 }
341 Ok(())
342}
343
344fn value_field(
345 record: &v1::ValueRecord,
346 field_id: u32,
347) -> Result<&v1::Value, GeneratedCommandError> {
348 record
349 .fields
350 .iter()
351 .find(|field| field.field_id == Some(field_id))
352 .and_then(|field| field.value.as_ref())
353 .ok_or(GeneratedCommandError::InvalidOutcomeShape)
354}
355
356fn record_field(
357 record: &v1::ValueRecord,
358 field_id: u32,
359) -> Result<&v1::ValueRecord, GeneratedCommandError> {
360 match value_field(record, field_id)?.kind.as_ref() {
361 Some(v1::value::Kind::RecordValue(value)) => Ok(value),
362 _ => Err(GeneratedCommandError::InvalidOutcomeShape),
363 }
364}
365
366fn i64_field(record: &v1::ValueRecord, field_id: u32) -> Result<i64, GeneratedCommandError> {
367 match value_field(record, field_id)?.kind.as_ref() {
368 Some(v1::value::Kind::I64Value(value)) => Ok(*value),
369 _ => Err(GeneratedCommandError::InvalidOutcomeShape),
370 }
371}
372
373fn uuid_field(record: &v1::ValueRecord, field_id: u32) -> Result<[u8; 16], GeneratedCommandError> {
374 match value_field(record, field_id)?.kind.as_ref() {
375 Some(v1::value::Kind::UuidValue(value)) => value
376 .as_slice()
377 .try_into()
378 .map_err(|_| GeneratedCommandError::InvalidOutcomeShape),
379 _ => Err(GeneratedCommandError::InvalidOutcomeShape),
380 }
381}
382
383fn timestamp_field(
384 record: &v1::ValueRecord,
385 field_id: u32,
386) -> Result<Timestamp, GeneratedCommandError> {
387 match value_field(record, field_id)?.kind.as_ref() {
388 Some(v1::value::Kind::TimestampValue(value)) => Timestamp::new(value.seconds, value.nanos)
389 .map_err(|_| GeneratedCommandError::InvalidOutcomeShape),
390 _ => Err(GeneratedCommandError::InvalidOutcomeShape),
391 }
392}
393
394fn decimal_field(record: &v1::ValueRecord, field_id: u32) -> Result<Amount, GeneratedCommandError> {
395 match value_field(record, field_id)?.kind.as_ref() {
396 Some(v1::value::Kind::DecimalValue(value)) if value.scale == 2 => {
397 let coefficient = decode_i128(&value.coefficient_twos_complement)?;
398 Amount::from_minor_units(coefficient).ok_or(GeneratedCommandError::InvalidOutcomeShape)
399 }
400 _ => Err(GeneratedCommandError::InvalidOutcomeShape),
401 }
402}
403
404fn minimum_field(record: &v1::ValueRecord, field_id: u32) -> Result<Amount, GeneratedCommandError> {
405 let amount = decimal_field(record, field_id)?;
406 if !(-99..=99).contains(&amount.minor_units()) {
407 return Err(GeneratedCommandError::InvalidOutcomeShape);
408 }
409 Ok(amount)
410}
411
412fn record(fields: Vec<v1::ValueField>) -> v1::Value {
413 v1::Value {
414 kind: Some(v1::value::Kind::RecordValue(v1::ValueRecord { fields })),
415 }
416}
417
418fn field(field_id: u32, value: v1::Value) -> v1::ValueField {
419 v1::ValueField {
420 field_id: Some(field_id),
421 name: String::new(),
422 value: Some(value),
423 }
424}
425
426fn i64_value(value: i64) -> v1::Value {
427 v1::Value {
428 kind: Some(v1::value::Kind::I64Value(value)),
429 }
430}
431
432fn string_value(value: String) -> v1::Value {
433 v1::Value {
434 kind: Some(v1::value::Kind::StringValue(value)),
435 }
436}
437
438fn uuid_value(value: [u8; 16]) -> v1::Value {
439 v1::Value {
440 kind: Some(v1::value::Kind::UuidValue(value.to_vec())),
441 }
442}
443
444fn decimal_value(value: Amount) -> v1::Value {
445 v1::Value {
446 kind: Some(v1::value::Kind::DecimalValue(v1::Decimal {
447 coefficient_twos_complement: encode_i128(value.minor_units()),
448 scale: 2,
449 precision: Some(28),
450 })),
451 }
452}
453
454fn encode_i128(value: i128) -> Vec<u8> {
455 let bytes = value.to_be_bytes();
456 let mut first = 0;
457 while first < bytes.len() - 1 {
458 let redundant_zero = bytes[first] == 0 && bytes[first + 1] & 0x80 == 0;
459 let redundant_one = bytes[first] == 0xff && bytes[first + 1] & 0x80 != 0;
460 if !redundant_zero && !redundant_one {
461 break;
462 }
463 first += 1;
464 }
465 bytes[first..].to_vec()
466}
467
468fn decode_i128(bytes: &[u8]) -> Result<i128, GeneratedCommandError> {
469 if bytes.is_empty() || bytes.len() > 16 {
470 return Err(GeneratedCommandError::InvalidOutcomeShape);
471 }
472 if bytes.len() > 1
473 && ((bytes[0] == 0 && bytes[1] & 0x80 == 0) || (bytes[0] == 0xff && bytes[1] & 0x80 != 0))
474 {
475 return Err(GeneratedCommandError::InvalidOutcomeShape);
476 }
477 let mut full = if bytes[0] & 0x80 == 0 {
478 [0_u8; 16]
479 } else {
480 [0xff_u8; 16]
481 };
482 full[16 - bytes.len()..].copy_from_slice(bytes);
483 Ok(i128::from_be_bytes(full))
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 #[test]
491 fn generated_create_input_uses_frozen_field_ids_and_exact_decimal() {
492 let input = CreateBudget {
493 idempotency_key: "create-1".to_owned(),
494 organization_id: [0x11; 16],
495 fiscal_year: 2026,
496 approved_amount: Amount::from_minor_units(10_000).expect("amount"),
497 };
498 let command = input.idempotent_command().expect("command");
499 assert_eq!(command.command_name(), "CreateBudget");
500 let Some(v1::value::Kind::RecordValue(record)) = command.input().kind.as_ref() else {
501 panic!("record input")
502 };
503 assert_eq!(
504 record
505 .fields
506 .iter()
507 .map(|field| field.field_id.expect("ID"))
508 .collect::<Vec<_>>(),
509 [1, 2, 3, 4]
510 );
511 let Some(v1::value::Kind::DecimalValue(decimal)) = record.fields[1]
512 .value
513 .as_ref()
514 .and_then(|value| value.kind.as_ref())
515 else {
516 panic!("decimal")
517 };
518 assert_eq!(decimal.scale, 2);
519 assert_eq!(
520 decode_i128(&decimal.coefficient_twos_complement),
521 Ok(10_000)
522 );
523
524 let request_id =
525 RequestId::from_unix_milliseconds_and_random(1, [7; 10]).expect("request ID");
526 let recovery = input.outcome_request(request_id).expect("recovery request");
527 assert_eq!(recovery.request_id, request_id.into_bytes());
528 assert_eq!(recovery.contract_lineage, CONTRACT_LINEAGE);
529 assert_eq!(recovery.command_name, "CreateBudget");
530 assert_eq!(recovery.idempotency_key, "create-1");
531 }
532
533 #[test]
534 fn generated_allocate_input_uses_every_frozen_field_and_exact_value() {
535 let input = AllocateBudget {
536 idempotency_key: "allocate-1".to_owned(),
537 organization_id: [0x11; 16],
538 fiscal_year: 2026,
539 matter_id: [0x22; 16],
540 amount: Amount::from_minor_units(8_000).expect("amount"),
541 };
542 let command = input.idempotent_command().expect("command");
543 assert_eq!(command.command_name(), "AllocateBudget");
544 let Some(v1::value::Kind::RecordValue(record)) = command.input().kind.as_ref() else {
545 panic!("record input")
546 };
547 assert_eq!(
548 record
549 .fields
550 .iter()
551 .map(|field| field.field_id.expect("ID"))
552 .collect::<Vec<_>>(),
553 [1, 2, 3, 4, 5]
554 );
555 assert_eq!(decimal_field(record, 1), Ok(input.amount));
556 assert_eq!(uuid_field(record, 2), Ok(input.matter_id));
557 assert_eq!(i64_field(record, 3), Ok(input.fiscal_year));
558 assert!(matches!(
559 value_field(record, 4).and_then(|value| match value.kind.as_ref() {
560 Some(v1::value::Kind::StringValue(value)) => Ok(value.as_str()),
561 _ => Err(GeneratedCommandError::InvalidOutcomeShape),
562 }),
563 Ok("allocate-1")
564 ));
565 assert_eq!(uuid_field(record, 5), Ok(input.organization_id));
566
567 let request_id =
568 RequestId::from_unix_milliseconds_and_random(2, [8; 10]).expect("request ID");
569 let recovery = input.outcome_request(request_id).expect("recovery request");
570 assert_eq!(recovery.request_id, request_id.into_bytes());
571 assert_eq!(recovery.contract_lineage, CONTRACT_LINEAGE);
572 assert_eq!(recovery.command_name, "AllocateBudget");
573 assert_eq!(recovery.idempotency_key, "allocate-1");
574 }
575
576 #[test]
577 fn generated_happy_path_outcomes_decode_complete_budget_values() {
578 let budget = Budget {
579 organization_id: [0x11; 16],
580 fiscal_year: 2026,
581 approved_amount: Amount::from_minor_units(10_000).expect("approved"),
582 allocated_amount: Amount::from_minor_units(8_000).expect("allocated"),
583 updated_at: Timestamp::new(1_700_000_000, 123_456_789).expect("timestamp"),
584 };
585 let create = CreateBudget {
586 idempotency_key: "create-1".to_owned(),
587 organization_id: budget.organization_id,
588 fiscal_year: budget.fiscal_year,
589 approved_amount: budget.approved_amount,
590 };
591 let create_response = successful_response(
592 v1::execute_command_response::CompletionStatus::Committed,
593 &CREATE_BUDGET_PLAN_HASH,
594 "BudgetCreated",
595 record(vec![field(1, budget_value(budget))]),
596 );
597 assert_eq!(
598 create.decode_outcome(&create_response),
599 Ok(CreateBudgetOutcome::BudgetCreated { budget })
600 );
601
602 let allocate = AllocateBudget {
603 idempotency_key: "allocate-1".to_owned(),
604 organization_id: budget.organization_id,
605 fiscal_year: budget.fiscal_year,
606 matter_id: [0x22; 16],
607 amount: Amount::from_minor_units(8_000).expect("amount"),
608 };
609 let remaining = Amount::from_minor_units(2_000).expect("remaining");
610 let allocate_response = successful_response(
611 v1::execute_command_response::CompletionStatus::Replayed,
612 &ALLOCATE_BUDGET_PLAN_HASH,
613 "Allocated",
614 record(vec![
615 field(1, budget_value(budget)),
616 field(2, decimal_value(remaining)),
617 ]),
618 );
619 assert_eq!(
620 allocate.decode_outcome(&allocate_response),
621 Ok(AllocateBudgetOutcome::Allocated { budget, remaining })
622 );
623 }
624
625 #[test]
626 fn generated_idempotency_recovery_rejects_unbound_invalid_keys() {
627 let input = AllocateBudget {
628 idempotency_key: String::new(),
629 organization_id: [0x11; 16],
630 fiscal_year: 2026,
631 matter_id: [0x22; 16],
632 amount: Amount::from_minor_units(100).expect("amount"),
633 };
634 let request_id =
635 RequestId::from_unix_milliseconds_and_random(3, [9; 10]).expect("request ID");
636 assert_eq!(
637 input.idempotent_command(),
638 Err(GeneratedCommandError::InvalidInputShape)
639 );
640 assert_eq!(
641 input.outcome_request(request_id),
642 Err(GeneratedCommandError::InvalidInputShape)
643 );
644 }
645
646 #[test]
647 fn generated_values_never_wrap_decimal_coefficients() {
648 assert!(Amount::from_minor_units(MAX_DECIMAL_COEFFICIENT).is_some());
649 assert!(Amount::from_minor_units(-MAX_DECIMAL_COEFFICIENT).is_some());
650 assert!(Amount::from_minor_units(MAX_DECIMAL_COEFFICIENT + 1).is_none());
651 assert!(Amount::from_minor_units(-MAX_DECIMAL_COEFFICIENT - 1).is_none());
652 for value in [i128::MIN, -129, -128, -1, 0, 1, 127, 128, i128::MAX] {
653 assert_eq!(decode_i128(&encode_i128(value)), Ok(value));
654 }
655 }
656
657 #[test]
658 fn generated_plan_hashes_match_the_checked_compiler_fixture() {
659 let metadata = include_str!("../../../../fixtures/compiler/bundle-metadata.txt");
660 assert_eq!(fixture_value(metadata, "lineage"), CONTRACT_LINEAGE);
661 assert_eq!(
662 fixture_value(metadata, "contract_version"),
663 CONTRACT_VERSION.to_string()
664 );
665
666 let plans = include_str!("../../../../fixtures/compiler/command-plans.txt");
667 assert_plan_fixture(
668 plans,
669 "CreateBudget",
670 CONTRACT_VERSION,
671 &CREATE_BUDGET_PLAN_HASH,
672 );
673 assert_plan_fixture(
674 plans,
675 "AllocateBudget",
676 CONTRACT_VERSION,
677 &ALLOCATE_BUDGET_PLAN_HASH,
678 );
679 }
680
681 #[test]
682 fn generated_decoder_rejects_a_structurally_valid_wrong_plan() {
683 let command = CreateBudget {
684 idempotency_key: "create-1".to_owned(),
685 organization_id: [0x11; 16],
686 fiscal_year: 2026,
687 approved_amount: Amount::from_minor_units(10_000).expect("amount"),
688 };
689 let response = v1::ExecuteCommandResponse {
690 status: v1::execute_command_response::CompletionStatus::Committed as i32,
691 commit_sequence: 1,
692 contract_version: CONTRACT_VERSION,
693 plan_hash: vec![0; 32],
694 outcome_type: "BudgetCreated".to_owned(),
695 outcome: Some(record(Vec::new())),
696 provenance_uri: "riffdb://provenance/018f22e2-79b7-7cc3-a85f-250f0f80c78e".to_owned(),
697 durability_mode: "sync".to_owned(),
698 outcome_uri: None,
699 history_incarnation: 1,
700 };
701 assert_eq!(
702 command.decode_outcome(&response),
703 Err(GeneratedCommandError::InvalidOutcomeShape)
704 );
705 }
706
707 fn budget_value(budget: Budget) -> v1::Value {
708 record(vec![
709 field(
710 1,
711 v1::Value {
712 kind: Some(v1::value::Kind::TimestampValue(v1::Timestamp {
713 seconds: budget.updated_at.seconds(),
714 nanos: budget.updated_at.nanos(),
715 })),
716 },
717 ),
718 field(2, i64_value(budget.fiscal_year)),
719 field(3, decimal_value(budget.approved_amount)),
720 field(4, uuid_value(budget.organization_id)),
721 field(5, decimal_value(budget.allocated_amount)),
722 ])
723 }
724
725 fn successful_response(
726 status: v1::execute_command_response::CompletionStatus,
727 plan_hash: &[u8; 32],
728 outcome_type: &str,
729 outcome: v1::Value,
730 ) -> v1::ExecuteCommandResponse {
731 v1::ExecuteCommandResponse {
732 status: status as i32,
733 commit_sequence: 1,
734 contract_version: CONTRACT_VERSION,
735 plan_hash: plan_hash.to_vec(),
736 outcome_type: outcome_type.to_owned(),
737 outcome: Some(outcome),
738 provenance_uri: "riffdb://provenance/018f22e2-79b7-7cc3-a85f-250f0f80c78e".to_owned(),
739 durability_mode: "sync".to_owned(),
740 outcome_uri: None,
741 history_incarnation: 1,
742 }
743 }
744
745 fn fixture_value<'fixture>(fixture: &'fixture str, key: &str) -> &'fixture str {
746 fixture
747 .lines()
748 .find_map(|line| line.strip_prefix(key)?.strip_prefix('='))
749 .unwrap_or_else(|| panic!("missing compiler fixture key {key}"))
750 }
751
752 fn assert_plan_fixture(
753 fixture: &str,
754 command_name: &str,
755 contract_version: u64,
756 plan_hash: &[u8; 32],
757 ) {
758 let block = fixture
759 .split("\n[command ")
760 .find(|block| {
761 block
762 .lines()
763 .any(|line| line == format!("name={command_name}"))
764 })
765 .unwrap_or_else(|| panic!("missing compiler plan for {command_name}"));
766 assert_eq!(
767 fixture_value(block, "contract_version"),
768 contract_version.to_string()
769 );
770 assert_eq!(fixture_value(block, "plan_hash"), lowercase_hex(plan_hash));
771 }
772
773 fn lowercase_hex(bytes: &[u8]) -> String {
774 const DIGITS: &[u8; 16] = b"0123456789abcdef";
775 let mut output = String::with_capacity(bytes.len() * 2);
776 for byte in bytes {
777 output.push(char::from(DIGITS[usize::from(byte >> 4)]));
778 output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
779 }
780 output
781 }
782}