ic_backup/model/membership/mod.rs
1//! Ephemeral membership checks bound to original intent; no persisted fresh-authority flags.
2
3use crate::model::evidence_digest::EvidenceDigest;
4use crate::model::{
5 artifacts::ArtifactChecksumRecord,
6 attempt_journal::OperationBindingRecord,
7 inventory::InventoryRecord,
8 operation_plan::{OperationPlanError, OperationPlanRecord, PlanContextRecord},
9};
10use thiserror::Error;
11
12/// Maximum remote observations described by one membership request, without spending authority.
13pub const MAX_MEMBERSHIP_REMOTE_OBSERVATIONS: u32 = 1024;
14
15/// Explicit effect boundary being checked; neither variant admits an effect.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum MembershipBoundary {
18 /// Current membership before one reviewed effect.
19 BeforeEffect,
20 /// Current membership after that effect; not proof of uninterrupted membership.
21 AfterEffect,
22}
23
24/// Immutable ephemeral request derived from the original plan and exact operation.
25///
26/// The integration supplies a fresh unpredictable challenge and owns its uniqueness,
27/// current observation timing, coherent custody and separately approved observation
28/// spending. The call ceiling is descriptive, not an allowance or journal reservation.
29/// This type has no serialization, persistence or default-provider admission.
30#[derive(Clone, Debug)]
31pub struct MembershipObservationRequest<'a> {
32 plan: &'a OperationPlanRecord,
33 binding: OperationBindingRecord,
34 challenge: ArtifactChecksumRecord,
35 boundary: MembershipBoundary,
36 max_remote_observations: u32,
37}
38
39impl<'a> MembershipObservationRequest<'a> {
40 /// Bind a challenge, explicit boundary and bounded call ceiling to original intent.
41 ///
42 /// Zero remote calls permits a qualified local integration; it cannot authorize
43 /// a paid probe. Original mutation/reconciliation allowances are never changed.
44 /// # Errors
45 /// Rejects unknown operations or an excessive descriptive call ceiling.
46 pub fn new(
47 plan: &'a OperationPlanRecord,
48 operation_sequence: u64,
49 challenge: ArtifactChecksumRecord,
50 boundary: MembershipBoundary,
51 max_remote_observations: u32,
52 ) -> Result<Self, MembershipRequestError> {
53 if max_remote_observations > MAX_MEMBERSHIP_REMOTE_OBSERVATIONS {
54 return Err(MembershipRequestError::ObservationLimitTooLarge);
55 }
56 let binding = plan.operation_binding(operation_sequence)?;
57 Ok(Self {
58 plan,
59 binding,
60 challenge,
61 boundary,
62 max_remote_observations,
63 })
64 }
65 /// Read original intent, operation, context, target and mutation-payload identity.
66 #[must_use]
67 pub const fn binding(&self) -> &OperationBindingRecord {
68 &self.binding
69 }
70 /// Read the full expected declared inventory, including unselected parents.
71 #[must_use]
72 pub const fn inventory(&self) -> &InventoryRecord {
73 self.plan.inventory()
74 }
75 /// Read exact canonical selected principals; this is not lifecycle order.
76 #[must_use]
77 pub fn selected_targets(&self) -> &[String] {
78 self.plan.selected_targets()
79 }
80 /// Read the integration-owned challenge; its value alone establishes no freshness.
81 #[must_use]
82 pub const fn challenge(&self) -> &ArtifactChecksumRecord {
83 &self.challenge
84 }
85 /// Read the explicit effect boundary.
86 #[must_use]
87 pub const fn boundary(&self) -> MembershipBoundary {
88 self.boundary
89 }
90 /// Read the descriptive remote-call ceiling, not a dispatch or spending permit.
91 #[must_use]
92 pub const fn max_remote_observations(&self) -> u32 {
93 self.max_remote_observations
94 }
95 /// Hash original full plan intent, operation, challenge, boundary and call ceiling.
96 ///
97 /// Encoding uses the NUL-terminated ASCII v1 domain, canonical 64-byte ASCII
98 /// intent, big-endian u64 sequence, canonical 64-byte ASCII challenge, boundary
99 /// byte (before=0, after=1), then big-endian u32 descriptive call ceiling.
100 /// Context/inventory/selection/request/original allowances bind through full
101 /// original intent. Observation results, revision and evidence are excluded.
102 #[must_use]
103 pub fn digest(&self) -> ArtifactChecksumRecord {
104 let mut bytes = EvidenceDigest::new(b"ic-backup/membership-request/v1\0");
105 bytes.append(self.binding.intent().as_bytes());
106 bytes.append(&self.binding.operation_sequence().to_be_bytes());
107 bytes.append(self.challenge.hash().as_bytes());
108 bytes.byte(match self.boundary {
109 MembershipBoundary::BeforeEffect => 0,
110 MembershipBoundary::AfterEffect => 1,
111 });
112 bytes.append(&self.max_remote_observations.to_be_bytes());
113 bytes.finish()
114 }
115}
116
117/// Passive current-observation result supplied by a trusted membership integration.
118///
119/// No Serialize/Deserialize implementation exists: retained JSON is not a current
120/// observation. The caller must qualify the provider and its actual observations;
121/// these fields and opaque evidence digests are not self-authenticating.
122#[derive(Clone, Debug)]
123pub struct MembershipObservation {
124 /// Exact digest of the current challenge/boundary-bound request.
125 pub request: ArtifactChecksumRecord,
126 /// Actually observed network/caller/release, not copied expected labels.
127 pub context: PlanContextRecord,
128 /// Complete current inventory under the existing 1,024-target forest bound.
129 pub inventory: InventoryRecord,
130 /// Optional opaque current authority revision; equality proves no continuity.
131 pub revision: Option<ArtifactChecksumRecord>,
132 /// Exact opaque evidence identifier qualified by the integration owner.
133 pub evidence: ArtifactChecksumRecord,
134 /// Actual remote observations; each needs separate prior approved accounting.
135 pub remote_observations: u32,
136}
137
138/// Typed rejection before requesting any provider observation.
139#[derive(Debug, Error)]
140pub enum MembershipRequestError {
141 /// Descriptive remote-call ceiling exceeds the maintained bound.
142 #[error("membership call ceiling exceeds {MAX_MEMBERSHIP_REMOTE_OBSERVATIONS}")]
143 ObservationLimitTooLarge,
144 /// Original plan cannot derive the named operation binding.
145 #[error(transparent)]
146 Plan(#[from] OperationPlanError),
147}
148
149#[cfg(test)]
150mod tests;