myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

state_config.rs (23757B)


      1 //! Offline append-only configuration-binding lifecycle.
      2 
      3 use core::fmt;
      4 use std::{
      5     collections::{BTreeMap, BTreeSet},
      6     error::Error,
      7 };
      8 
      9 use radroots_service_sqlite::{
     10     MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, ServiceSqliteTransaction,
     11     ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind,
     12 };
     13 use serde_json::Value;
     14 use sqlx::{QueryBuilder, Row, Sqlite};
     15 
     16 use crate::{
     17     MYC_PROVIDER_CONTRACT_VERSION, MycConfigDocumentV1, MycStateRepository,
     18     state_repository::{
     19         PersistedMetadata, RepositoryOperationError, read_latest_config_binding,
     20         require_expected_metadata,
     21     },
     22 };
     23 
     24 /// Maximum number of immutable configuration generations retained by one instance.
     25 pub const MYC_CONFIG_BINDING_MAX_GENERATIONS: u16 = 1024;
     26 
     27 const READ_LATEST_HEADER_SQL: &str = r#"SELECT generation, applied_at_unix_s
     28 FROM myc_config_bindings
     29 ORDER BY generation DESC
     30 LIMIT 1"#;
     31 
     32 const RELAY_HAS_NONTERMINAL_JOB_SQL: &str = r#"SELECT EXISTS (
     33     SELECT 1
     34     FROM delivery_targets AS target
     35     JOIN delivery_jobs AS job ON job.job_id = target.job_id
     36     WHERE target.relay_id = ? AND job.status IN ('pending', 'active')
     37     LIMIT 1
     38 ) AS is_blocked"#;
     39 
     40 const REVOKE_ACTIVE_CONNECTIONS_SQL: &str = r#"UPDATE connections
     41 SET status = 'expired', updated_at_unix_ms = MAX(updated_at_unix_ms, ?),
     42     authorized_until_unix_ms = NULL
     43 WHERE status = 'active'"#;
     44 
     45 const DENY_PENDING_CONNECTIONS_SQL: &str = r#"UPDATE connections
     46 SET status = 'denied', updated_at_unix_ms = MAX(updated_at_unix_ms, ?),
     47     authorized_until_unix_ms = NULL
     48 WHERE status = 'pending'"#;
     49 
     50 const EXPIRE_ALL_PENDING_CHALLENGES_SQL: &str = r#"UPDATE connection_auth_challenges
     51 SET state = 'expired',
     52     resolved_at_unix_ms = MAX(issued_at_unix_ms, ?)
     53 WHERE state = 'pending'"#;
     54 
     55 const INSERT_CONFIG_BINDING_SQL: &str = r#"INSERT INTO myc_config_bindings (
     56     generation, normalized_config_sha256, transport_public_key, user_public_key,
     57     discovery_public_key, config_contract_version, state_contract_version,
     58     operator_contract_version, status_contract_version, applied_at_unix_s,
     59     service_version, service_commit, lib_revision, rust_version, target,
     60     feature_profile, provider_contract_version
     61 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#;
     62 
     63 /// Stable offline configuration-application failure classes.
     64 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     65 pub enum MycConfigApplyErrorKind {
     66     InvalidMode,
     67     InvalidInput,
     68     Binding,
     69     PolicyConflict,
     70     ResourceExhausted,
     71     Transaction,
     72     CommitOutcomeUnknown,
     73 }
     74 
     75 impl MycConfigApplyErrorKind {
     76     /// Returns the stable machine-readable failure code.
     77     #[must_use]
     78     pub const fn code(self) -> &'static str {
     79         match self {
     80             Self::InvalidMode => "config_apply_mode_invalid",
     81             Self::InvalidInput => "config_apply_input_invalid",
     82             Self::Binding => "config_apply_binding_invalid",
     83             Self::PolicyConflict => "config_apply_policy_conflict",
     84             Self::ResourceExhausted => "resource_exhausted",
     85             Self::Transaction => "config_apply_transaction_failed",
     86             Self::CommitOutcomeUnknown => "config_apply_commit_outcome_unknown",
     87         }
     88     }
     89 }
     90 
     91 /// Source-free offline configuration-application failure.
     92 #[derive(Clone, Copy, PartialEq, Eq)]
     93 pub struct MycConfigApplyError {
     94     kind: MycConfigApplyErrorKind,
     95 }
     96 
     97 impl MycConfigApplyError {
     98     const fn new(kind: MycConfigApplyErrorKind) -> Self {
     99         Self { kind }
    100     }
    101 
    102     /// Returns the stable failure class.
    103     #[must_use]
    104     pub const fn kind(self) -> MycConfigApplyErrorKind {
    105         self.kind
    106     }
    107 
    108     /// Returns the stable machine-readable failure code.
    109     #[must_use]
    110     pub const fn code(self) -> &'static str {
    111         self.kind.code()
    112     }
    113 }
    114 
    115 impl fmt::Display for MycConfigApplyError {
    116     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    117         formatter.write_str(match self.kind {
    118             MycConfigApplyErrorKind::InvalidMode => {
    119                 "Myc configuration apply requires an offline writable state host"
    120             }
    121             MycConfigApplyErrorKind::InvalidInput => "Myc configuration apply evidence is invalid",
    122             MycConfigApplyErrorKind::Binding => "Myc configuration history binding is invalid",
    123             MycConfigApplyErrorKind::PolicyConflict => {
    124                 "Myc configuration change conflicts with retained work"
    125             }
    126             MycConfigApplyErrorKind::ResourceExhausted => {
    127                 "Myc configuration history capacity is exhausted"
    128             }
    129             MycConfigApplyErrorKind::Transaction => "Myc configuration apply transaction failed",
    130             MycConfigApplyErrorKind::CommitOutcomeUnknown => {
    131                 "Myc configuration apply commit outcome is unknown"
    132             }
    133         })
    134     }
    135 }
    136 
    137 impl fmt::Debug for MycConfigApplyError {
    138     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    139         formatter
    140             .debug_struct("MycConfigApplyError")
    141             .field("kind", &self.kind)
    142             .finish()
    143     }
    144 }
    145 
    146 impl Error for MycConfigApplyError {}
    147 
    148 /// Committed immutable configuration-generation evidence.
    149 #[derive(Clone, Copy, PartialEq, Eq)]
    150 pub struct MycConfigApplyOutcome {
    151     generation: u16,
    152     revoked_connections: u64,
    153     revoked_challenges: u64,
    154 }
    155 
    156 impl MycConfigApplyOutcome {
    157     /// Returns the committed consecutive configuration generation.
    158     #[must_use]
    159     pub const fn generation(self) -> u16 {
    160         self.generation
    161     }
    162 
    163     /// Returns the number of connection records revoked by the apply.
    164     #[must_use]
    165     pub const fn revoked_connection_count(self) -> u64 {
    166         self.revoked_connections
    167     }
    168 
    169     /// Returns the number of pending challenges revoked by the apply.
    170     #[must_use]
    171     pub const fn revoked_challenge_count(self) -> u64 {
    172         self.revoked_challenges
    173     }
    174 }
    175 
    176 impl fmt::Debug for MycConfigApplyOutcome {
    177     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    178         formatter
    179             .debug_struct("MycConfigApplyOutcome")
    180             .field("generation", &self.generation)
    181             .field("revoked_connections", &self.revoked_connections)
    182             .field("revoked_challenges", &self.revoked_challenges)
    183             .finish()
    184     }
    185 }
    186 
    187 impl MycStateRepository<'_> {
    188     #[cfg(any(target_os = "linux", target_os = "macos"))]
    189     pub(crate) async fn current_configuration_generation(
    190         &self,
    191     ) -> Result<u16, MycConfigApplyError> {
    192         self.host()
    193             .transaction(|transaction| {
    194                 Box::pin(async move {
    195                     read_latest_header(transaction)
    196                         .await
    197                         .map(|(generation, _)| generation)
    198                 })
    199             })
    200             .await
    201             .map_err(map_apply_transaction_error)
    202     }
    203 
    204     /// Atomically applies one complete candidate configuration while offline.
    205     ///
    206     /// The current document must match the latest durable binding. The candidate
    207     /// is already structurally and semantically admitted by its sealed type.
    208     /// Unsafe relay changes are rejected while nonterminal jobs retain the relay.
    209     pub async fn apply_configuration(
    210         &self,
    211         current: &MycConfigDocumentV1,
    212         candidate: &MycConfigDocumentV1,
    213         applied_at: MigrationAppliedAtUnixSeconds,
    214         build: &MigrationBuildIdentity,
    215     ) -> Result<MycConfigApplyOutcome, MycConfigApplyError> {
    216         if !self.is_writable() {
    217             return Err(MycConfigApplyError::new(
    218                 MycConfigApplyErrorKind::InvalidMode,
    219             ));
    220         }
    221         if current.profile() != candidate.profile()
    222             || candidate.provider_contract().bindings().is_empty()
    223             || !valid_build(candidate, build)
    224         {
    225             return Err(MycConfigApplyError::new(
    226                 MycConfigApplyErrorKind::InvalidInput,
    227             ));
    228         }
    229         let current_binding = PersistedMetadata::from_configuration(current)
    230             .map_err(|_| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?;
    231         if current_binding != PersistedMetadata::from(self.expected()) {
    232             return Err(MycConfigApplyError::new(MycConfigApplyErrorKind::Binding));
    233         }
    234         let candidate_binding = PersistedMetadata::from_configuration(candidate)
    235             .map_err(|_| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?;
    236         let exact_replay = candidate_binding == current_binding;
    237         let changed_relays = changed_existing_relays(current, candidate)?;
    238         let candidate_permissions = permission_ceiling(candidate)?;
    239         let permissions_narrowed = permission_ceiling(current)?
    240             .iter()
    241             .any(|permission| !candidate_permissions.contains(permission));
    242         let identities_changed = identities_changed(&current_binding, &candidate_binding);
    243         let applied_at_unix_s = applied_at.get();
    244         let applied_at_unix_ms =
    245             i64::try_from(applied_at_unix_s.saturating_mul(1000)).unwrap_or(i64::MAX);
    246         let build = build.clone();
    247 
    248         self.host()
    249             .transaction(move |transaction| {
    250                 Box::pin(async move {
    251                     require_expected_metadata(transaction, &current_binding).await?;
    252                     let (generation, latest_applied_at) = read_latest_header(transaction).await?;
    253                     if exact_replay {
    254                         return Ok(MycConfigApplyOutcome {
    255                             generation,
    256                             revoked_connections: 0,
    257                             revoked_challenges: 0,
    258                         });
    259                     }
    260                     if generation >= MYC_CONFIG_BINDING_MAX_GENERATIONS {
    261                         return Err(ConfigOperationError::ResourceExhausted);
    262                     }
    263                     if applied_at_unix_s < latest_applied_at {
    264                         return Err(ConfigOperationError::InvalidInput);
    265                     }
    266                     for relay_id in &changed_relays {
    267                         if relay_has_nonterminal_job(transaction, relay_id).await? {
    268                             return Err(ConfigOperationError::PolicyConflict);
    269                         }
    270                     }
    271                     let (revoked_connections, revoked_challenges) = if identities_changed {
    272                         revoke_for_identity_change(transaction, applied_at_unix_ms).await?
    273                     } else if permissions_narrowed {
    274                         revoke_for_permission_narrowing(
    275                             transaction,
    276                             applied_at_unix_ms,
    277                             &candidate_permissions,
    278                         )
    279                         .await?
    280                     } else {
    281                         (0, 0)
    282                     };
    283                     let next_generation = generation + 1;
    284                     insert_binding(
    285                         transaction,
    286                         next_generation,
    287                         &candidate_binding,
    288                         applied_at_unix_s,
    289                         &build,
    290                     )
    291                     .await?;
    292                     match read_latest_config_binding(transaction).await? {
    293                         Some(actual) if actual == candidate_binding => {}
    294                         Some(_) | None => return Err(ConfigOperationError::Binding),
    295                     }
    296                     let (actual_generation, actual_applied_at) =
    297                         read_latest_header(transaction).await?;
    298                     if actual_generation != next_generation
    299                         || actual_applied_at != applied_at_unix_s
    300                     {
    301                         return Err(ConfigOperationError::Binding);
    302                     }
    303                     Ok(MycConfigApplyOutcome {
    304                         generation: next_generation,
    305                         revoked_connections,
    306                         revoked_challenges,
    307                     })
    308                 })
    309             })
    310             .await
    311             .map_err(map_apply_transaction_error)
    312     }
    313 }
    314 
    315 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    316 enum ConfigOperationError {
    317     InvalidInput,
    318     Binding,
    319     PolicyConflict,
    320     ResourceExhausted,
    321     Storage,
    322 }
    323 
    324 impl From<RepositoryOperationError> for ConfigOperationError {
    325     fn from(error: RepositoryOperationError) -> Self {
    326         match error {
    327             RepositoryOperationError::Binding => Self::Binding,
    328             RepositoryOperationError::Storage => Self::Storage,
    329         }
    330     }
    331 }
    332 
    333 fn valid_build(configuration: &MycConfigDocumentV1, build: &MigrationBuildIdentity) -> bool {
    334     build.config_contract_version() == configuration.schema_version()
    335         && build.state_contract_version() == crate::MYC_STATE_SCHEMA_VERSION
    336         && build.admin_contract_version() == crate::MYC_OPERATOR_CONTRACT_VERSION
    337         && build.status_contract_version() == crate::MYC_SIGNER_STATUS_CONTRACT_VERSION
    338         && build.provider_contract_version() == MYC_PROVIDER_CONTRACT_VERSION
    339 }
    340 
    341 fn identities_changed(current: &PersistedMetadata, candidate: &PersistedMetadata) -> bool {
    342     current.transport_public_key != candidate.transport_public_key
    343         || current.user_public_key != candidate.user_public_key
    344         || current.discovery_public_key != candidate.discovery_public_key
    345 }
    346 
    347 #[derive(PartialEq, Eq)]
    348 struct RelayBinding<'a> {
    349     url: &'a str,
    350     read: bool,
    351     write: bool,
    352     required: bool,
    353     authentication: &'a str,
    354 }
    355 
    356 fn relay_bindings(
    357     configuration: &MycConfigDocumentV1,
    358 ) -> Result<BTreeMap<&str, RelayBinding<'_>>, MycConfigApplyError> {
    359     configuration
    360         .normalized()
    361         .pointer("/relays")
    362         .and_then(Value::as_array)
    363         .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?
    364         .iter()
    365         .map(|relay| {
    366             let id = relay
    367                 .pointer("/id")
    368                 .and_then(Value::as_str)
    369                 .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?;
    370             let value = RelayBinding {
    371                 url: relay
    372                     .pointer("/url")
    373                     .and_then(Value::as_str)
    374                     .ok_or_else(|| {
    375                         MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput)
    376                     })?,
    377                 read: relay
    378                     .pointer("/read")
    379                     .and_then(Value::as_bool)
    380                     .ok_or_else(|| {
    381                         MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput)
    382                     })?,
    383                 write: relay
    384                     .pointer("/write")
    385                     .and_then(Value::as_bool)
    386                     .ok_or_else(|| {
    387                         MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput)
    388                     })?,
    389                 required: relay
    390                     .pointer("/required")
    391                     .and_then(Value::as_bool)
    392                     .ok_or_else(|| {
    393                         MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput)
    394                     })?,
    395                 authentication: relay
    396                     .pointer("/authentication")
    397                     .and_then(Value::as_str)
    398                     .ok_or_else(|| {
    399                         MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput)
    400                     })?,
    401             };
    402             Ok((id, value))
    403         })
    404         .collect()
    405 }
    406 
    407 fn changed_existing_relays(
    408     current: &MycConfigDocumentV1,
    409     candidate: &MycConfigDocumentV1,
    410 ) -> Result<Vec<Box<str>>, MycConfigApplyError> {
    411     let current = relay_bindings(current)?;
    412     let candidate = relay_bindings(candidate)?;
    413     Ok(current
    414         .into_iter()
    415         .filter(|(id, binding)| candidate.get(id).is_none_or(|next| next != binding))
    416         .map(|(id, _)| id.into())
    417         .collect())
    418 }
    419 
    420 fn permission_ceiling(
    421     configuration: &MycConfigDocumentV1,
    422 ) -> Result<BTreeSet<Box<str>>, MycConfigApplyError> {
    423     configuration
    424         .normalized()
    425         .pointer("/policy/permission_ceiling")
    426         .and_then(Value::as_array)
    427         .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))?
    428         .iter()
    429         .map(|permission| {
    430             permission
    431                 .as_str()
    432                 .map(Into::into)
    433                 .ok_or_else(|| MycConfigApplyError::new(MycConfigApplyErrorKind::InvalidInput))
    434         })
    435         .collect()
    436 }
    437 
    438 async fn read_latest_header(
    439     transaction: &mut ServiceSqliteTransaction<'_>,
    440 ) -> Result<(u16, u64), ConfigOperationError> {
    441     let rows = sqlx::query(READ_LATEST_HEADER_SQL)
    442         .fetch_all(&mut *transaction)
    443         .await
    444         .map_err(|_| ConfigOperationError::Storage)?;
    445     let [row] = rows.as_slice() else {
    446         return Err(ConfigOperationError::Binding);
    447     };
    448     let generation = row
    449         .try_get::<i64, _>("generation")
    450         .ok()
    451         .and_then(|value| u16::try_from(value).ok())
    452         .filter(|value| (1..=MYC_CONFIG_BINDING_MAX_GENERATIONS).contains(value))
    453         .ok_or(ConfigOperationError::Binding)?;
    454     let applied_at = row
    455         .try_get::<i64, _>("applied_at_unix_s")
    456         .ok()
    457         .and_then(|value| u64::try_from(value).ok())
    458         .ok_or(ConfigOperationError::Binding)?;
    459     Ok((generation, applied_at))
    460 }
    461 
    462 async fn relay_has_nonterminal_job(
    463     transaction: &mut ServiceSqliteTransaction<'_>,
    464     relay_id: &str,
    465 ) -> Result<bool, ConfigOperationError> {
    466     let row = sqlx::query(RELAY_HAS_NONTERMINAL_JOB_SQL)
    467         .bind(relay_id)
    468         .fetch_one(&mut *transaction)
    469         .await
    470         .map_err(|_| ConfigOperationError::Storage)?;
    471     match row.try_get::<i64, _>("is_blocked") {
    472         Ok(0) => Ok(false),
    473         Ok(1) => Ok(true),
    474         _ => Err(ConfigOperationError::Binding),
    475     }
    476 }
    477 
    478 async fn revoke_for_identity_change(
    479     transaction: &mut ServiceSqliteTransaction<'_>,
    480     observed_at_unix_ms: i64,
    481 ) -> Result<(u64, u64), ConfigOperationError> {
    482     let active = sqlx::query(REVOKE_ACTIVE_CONNECTIONS_SQL)
    483         .bind(observed_at_unix_ms)
    484         .execute(&mut *transaction)
    485         .await
    486         .map_err(|_| ConfigOperationError::Storage)?
    487         .rows_affected();
    488     let pending = sqlx::query(DENY_PENDING_CONNECTIONS_SQL)
    489         .bind(observed_at_unix_ms)
    490         .execute(&mut *transaction)
    491         .await
    492         .map_err(|_| ConfigOperationError::Storage)?
    493         .rows_affected();
    494     let challenges = sqlx::query(EXPIRE_ALL_PENDING_CHALLENGES_SQL)
    495         .bind(observed_at_unix_ms)
    496         .execute(&mut *transaction)
    497         .await
    498         .map_err(|_| ConfigOperationError::Storage)?
    499         .rows_affected();
    500     Ok((active.saturating_add(pending), challenges))
    501 }
    502 
    503 async fn revoke_for_permission_narrowing(
    504     transaction: &mut ServiceSqliteTransaction<'_>,
    505     observed_at_unix_ms: i64,
    506     permissions: &BTreeSet<Box<str>>,
    507 ) -> Result<(u64, u64), ConfigOperationError> {
    508     let connections =
    509         update_affected_connections(transaction, observed_at_unix_ms, permissions).await?;
    510     let challenges =
    511         update_affected_challenges(transaction, observed_at_unix_ms, permissions).await?;
    512     Ok((connections, challenges))
    513 }
    514 
    515 async fn update_affected_connections(
    516     transaction: &mut ServiceSqliteTransaction<'_>,
    517     observed_at_unix_ms: i64,
    518     permissions: &BTreeSet<Box<str>>,
    519 ) -> Result<u64, ConfigOperationError> {
    520     let mut query = QueryBuilder::<Sqlite>::new(
    521         "UPDATE connections SET status = 'expired', updated_at_unix_ms = \
    522          MAX(updated_at_unix_ms, ",
    523     );
    524     query.push_bind(observed_at_unix_ms).push(
    525         "), authorized_until_unix_ms = NULL WHERE status = 'active' AND EXISTS (\
    526          SELECT 1 FROM connection_permissions AS permission \
    527          WHERE permission.connection_id = connections.connection_id \
    528          AND permission.permission_scope = 'granted'",
    529     );
    530     push_not_in(&mut query, permissions);
    531     query.push(")");
    532     query
    533         .build()
    534         .execute(&mut *transaction)
    535         .await
    536         .map(|result| result.rows_affected())
    537         .map_err(|_| ConfigOperationError::Storage)
    538 }
    539 
    540 async fn update_affected_challenges(
    541     transaction: &mut ServiceSqliteTransaction<'_>,
    542     observed_at_unix_ms: i64,
    543     permissions: &BTreeSet<Box<str>>,
    544 ) -> Result<u64, ConfigOperationError> {
    545     let mut query = QueryBuilder::<Sqlite>::new(
    546         "UPDATE connection_auth_challenges SET state = 'expired', \
    547          resolved_at_unix_ms = MAX(issued_at_unix_ms, ",
    548     );
    549     query.push_bind(observed_at_unix_ms).push(
    550         ") WHERE state = 'pending' AND EXISTS (\
    551          SELECT 1 FROM connection_permissions AS permission \
    552          WHERE permission.connection_id = connection_auth_challenges.connection_id \
    553          AND permission.permission_scope = 'requested'",
    554     );
    555     push_not_in(&mut query, permissions);
    556     query.push(")");
    557     query
    558         .build()
    559         .execute(&mut *transaction)
    560         .await
    561         .map(|result| result.rows_affected())
    562         .map_err(|_| ConfigOperationError::Storage)
    563 }
    564 
    565 fn push_not_in(query: &mut QueryBuilder<Sqlite>, permissions: &BTreeSet<Box<str>>) {
    566     if permissions.is_empty() {
    567         return;
    568     }
    569     query.push(" AND permission.permission_code NOT IN (");
    570     let mut separated = query.separated(", ");
    571     for permission in permissions {
    572         separated.push_bind(permission.as_ref());
    573     }
    574     separated.push_unseparated(")");
    575 }
    576 
    577 async fn insert_binding(
    578     transaction: &mut ServiceSqliteTransaction<'_>,
    579     generation: u16,
    580     binding: &PersistedMetadata,
    581     applied_at_unix_s: u64,
    582     build: &MigrationBuildIdentity,
    583 ) -> Result<(), ConfigOperationError> {
    584     let result = sqlx::query(INSERT_CONFIG_BINDING_SQL)
    585         .bind(i64::from(generation))
    586         .bind(binding.normalized_config_sha256.as_slice())
    587         .bind(binding.transport_public_key.as_ref())
    588         .bind(binding.user_public_key.as_ref())
    589         .bind(binding.discovery_public_key.as_deref())
    590         .bind(i64::from(binding.config_contract_version))
    591         .bind(i64::from(binding.state_contract_version))
    592         .bind(i64::from(binding.operator_contract_version))
    593         .bind(i64::from(binding.status_contract_version))
    594         .bind(i64::try_from(applied_at_unix_s).map_err(|_| ConfigOperationError::InvalidInput)?)
    595         .bind(build.service_version())
    596         .bind(build.service_commit())
    597         .bind(build.lib_revision())
    598         .bind(build.rust_version())
    599         .bind(build.target())
    600         .bind(build.feature_profile())
    601         .bind(i64::from(build.provider_contract_version()))
    602         .execute(&mut *transaction)
    603         .await
    604         .map_err(|_| ConfigOperationError::Storage)?;
    605     (result.rows_affected() == 1)
    606         .then_some(())
    607         .ok_or(ConfigOperationError::Storage)
    608 }
    609 
    610 fn map_apply_transaction_error(
    611     error: ServiceSqliteTransactionError<ConfigOperationError>,
    612 ) -> MycConfigApplyError {
    613     if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown {
    614         return MycConfigApplyError::new(MycConfigApplyErrorKind::CommitOutcomeUnknown);
    615     }
    616     let kind = match error.operation_error() {
    617         Some(ConfigOperationError::InvalidInput) => MycConfigApplyErrorKind::InvalidInput,
    618         Some(ConfigOperationError::Binding) => MycConfigApplyErrorKind::Binding,
    619         Some(ConfigOperationError::PolicyConflict) => MycConfigApplyErrorKind::PolicyConflict,
    620         Some(ConfigOperationError::ResourceExhausted) => MycConfigApplyErrorKind::ResourceExhausted,
    621         Some(ConfigOperationError::Storage) | None => MycConfigApplyErrorKind::Transaction,
    622     };
    623     MycConfigApplyError::new(kind)
    624 }