-
Notifications
You must be signed in to change notification settings - Fork 0
feat(privacy): record provider field codes without source text #114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f96cbff
f46a14b
9f6371f
df734d7
929657f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| [package] | ||
| name = "provider_receipt" | ||
| description = "Provider-disclosure receipts that refuse source text and identity." | ||
| version.workspace = true | ||
| edition.workspace = true | ||
| rust-version.workspace = true | ||
| license.workspace = true | ||
| authors.workspace = true | ||
| repository.workspace = true | ||
| homepage.workspace = true | ||
| readme.workspace = true | ||
| keywords.workspace = true | ||
| categories.workspace = true | ||
| publish = false | ||
|
|
||
| [lints] | ||
| workspace = true |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| //! Fail-closed provider-receipt errors. | ||
|
|
||
| use std::fmt; | ||
|
|
||
| /// A fail-closed provider-receipt error. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| #[non_exhaustive] | ||
| pub enum ProviderReceiptError { | ||
| /// Raw source text was supplied to a provider receipt. | ||
| SourceTextNotDisclosable, | ||
| /// Source identity was supplied to a provider receipt. | ||
| SourceIdentityNotDisclosable, | ||
| /// Blanket PII masking was treated as a disclosure grant. | ||
| BlanketMaskIsNotAuthorization, | ||
| /// A receipt or recovery slice was empty or length-mismatched. | ||
| InvalidReceiptPayload, | ||
| } | ||
|
|
||
| impl fmt::Display for ProviderReceiptError { | ||
| fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| let message = match self { | ||
| Self::SourceTextNotDisclosable => "source text cannot appear in a provider receipt", | ||
| Self::SourceIdentityNotDisclosable => { | ||
| "source identity cannot appear in a provider receipt" | ||
| } | ||
| Self::BlanketMaskIsNotAuthorization => { | ||
| "blanket PII masking is not provider-disclosure authorization" | ||
| } | ||
| Self::InvalidReceiptPayload => "invalid provider-receipt payload", | ||
| }; | ||
| formatter.write_str(message) | ||
| } | ||
| } | ||
|
|
||
| impl std::error::Error for ProviderReceiptError {} | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::ProviderReceiptError; | ||
|
|
||
| #[test] | ||
| fn error_messages_are_stable() { | ||
| for (error, message) in [ | ||
| ( | ||
| ProviderReceiptError::SourceTextNotDisclosable, | ||
| "source text cannot appear in a provider receipt", | ||
| ), | ||
| ( | ||
| ProviderReceiptError::SourceIdentityNotDisclosable, | ||
| "source identity cannot appear in a provider receipt", | ||
| ), | ||
| ( | ||
| ProviderReceiptError::BlanketMaskIsNotAuthorization, | ||
| "blanket PII masking is not provider-disclosure authorization", | ||
| ), | ||
| ( | ||
| ProviderReceiptError::InvalidReceiptPayload, | ||
| "invalid provider-receipt payload", | ||
| ), | ||
| ] { | ||
| assert_eq!(error.to_string(), message); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| #![forbid(unsafe_code)] | ||
| #![deny(missing_docs)] | ||
| #![allow(clippy::cast_precision_loss)] | ||
| //! Provider-disclosure receipts that refuse source text and identity. | ||
| //! | ||
| //! A receipt records which field codes were sent to a model provider under one | ||
| //! purpose. It cannot carry source text or source identity, and blanket PII | ||
| //! masking is not a disclosure grant (ADR 0009). | ||
|
|
||
| mod error; | ||
| mod receipt; | ||
|
|
||
| /// Fail-closed provider-receipt errors. | ||
| pub use error::ProviderReceiptError; | ||
| /// One provider-disclosure receipt of field codes under a purpose. | ||
| pub use receipt::ProviderReceipt; | ||
| /// Fraction of recovered field codes that match known truth. | ||
| pub use receipt::receipt_recovery_rate; | ||
| /// Refuse to treat a blanket PII mask as provider-disclosure authorization. | ||
| pub use receipt::refuse_blanket_mask_as_disclosure; | ||
| /// Refuse to place source identity in a provider receipt. | ||
| pub use receipt::refuse_source_identity_in_receipt; | ||
| /// Refuse to place raw source text in a provider receipt. | ||
| pub use receipt::refuse_source_text_in_receipt; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| //! Purpose-bound field-code receipts for provider disclosure. | ||
|
|
||
| use crate::ProviderReceiptError; | ||
|
|
||
| /// One provider-disclosure receipt of field codes under a purpose. | ||
| #[derive(Clone, Debug, Eq, PartialEq)] | ||
| pub struct ProviderReceipt { | ||
| purpose_code: u16, | ||
| field_codes: Vec<u16>, | ||
| } | ||
|
|
||
| impl ProviderReceipt { | ||
| /// Record the field codes sent to a provider under one purpose. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`ProviderReceiptError::InvalidReceiptPayload`] when no field | ||
| /// codes are supplied. | ||
| pub fn new(purpose_code: u16, field_codes: &[u16]) -> Result<Self, ProviderReceiptError> { | ||
| if field_codes.is_empty() { | ||
| return Err(ProviderReceiptError::InvalidReceiptPayload); | ||
| } | ||
| Ok(Self { | ||
| purpose_code, | ||
| field_codes: field_codes.to_vec(), | ||
| }) | ||
| } | ||
|
|
||
| /// Purpose bound to the disclosure. | ||
| #[must_use] | ||
| pub const fn purpose_code(&self) -> u16 { | ||
| self.purpose_code | ||
| } | ||
|
|
||
| /// Field codes sent, never source text. | ||
| #[must_use] | ||
| pub fn field_codes(&self) -> &[u16] { | ||
| &self.field_codes | ||
| } | ||
| } | ||
|
|
||
| /// Refuse to place raw source text in a provider receipt. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Always returns [`ProviderReceiptError::SourceTextNotDisclosable`]. | ||
| pub fn refuse_source_text_in_receipt() -> Result<(), ProviderReceiptError> { | ||
| Err(ProviderReceiptError::SourceTextNotDisclosable) | ||
| } | ||
|
|
||
| /// Refuse to place source identity in a provider receipt. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Always returns [`ProviderReceiptError::SourceIdentityNotDisclosable`]. | ||
| pub fn refuse_source_identity_in_receipt() -> Result<(), ProviderReceiptError> { | ||
| Err(ProviderReceiptError::SourceIdentityNotDisclosable) | ||
| } | ||
|
|
||
| /// Refuse to treat a blanket PII mask as provider-disclosure authorization. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Always returns [`ProviderReceiptError::BlanketMaskIsNotAuthorization`]. | ||
| pub fn refuse_blanket_mask_as_disclosure() -> Result<(), ProviderReceiptError> { | ||
| Err(ProviderReceiptError::BlanketMaskIsNotAuthorization) | ||
| } | ||
|
|
||
| /// Fraction of recovered field codes that match known truth. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`ProviderReceiptError::InvalidReceiptPayload`] when the field-code | ||
| /// lengths differ. | ||
| pub fn receipt_recovery_rate( | ||
| truth: &ProviderReceipt, | ||
| decided: &ProviderReceipt, | ||
| ) -> Result<f64, ProviderReceiptError> { | ||
| if truth.field_codes.len() != decided.field_codes.len() { | ||
| return Err(ProviderReceiptError::InvalidReceiptPayload); | ||
| } | ||
| let mut matches = 0_u32; | ||
| for (truth_field, decided_field) in truth.field_codes.iter().zip(&decided.field_codes) { | ||
| if truth_field == decided_field { | ||
| matches += 1; | ||
| } | ||
| } | ||
| Ok(f64::from(matches) / truth.field_codes.len() as f64) | ||
| } | ||
|
Comment on lines
+75
to
+89
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Recovery rate is positional and ignores purpose
Was this helpful? React with 👍 or 👎 to provide feedback.
Comment on lines
+75
to
+89
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Recovery rate is position-wise, not set-based
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::{ | ||
| ProviderReceipt, receipt_recovery_rate, refuse_blanket_mask_as_disclosure, | ||
| refuse_source_identity_in_receipt, refuse_source_text_in_receipt, | ||
| }; | ||
| use crate::ProviderReceiptError; | ||
|
|
||
| #[test] | ||
| fn local_branches_cover_construct_and_fail_closed_paths() { | ||
| let receipt = ProviderReceipt::new(7, &[1, 2]).expect("receipt"); | ||
| assert_eq!(receipt.purpose_code(), 7); | ||
| assert_eq!(receipt.field_codes(), &[1, 2]); | ||
| let matched = receipt_recovery_rate(&receipt, &receipt).expect("rate"); | ||
| assert!((matched - 1.0).abs() < f64::EPSILON); | ||
| assert_eq!( | ||
| ProviderReceipt::new(7, &[]), | ||
| Err(ProviderReceiptError::InvalidReceiptPayload) | ||
| ); | ||
| let short = ProviderReceipt::new(7, &[1]).expect("short"); | ||
| assert_eq!( | ||
| receipt_recovery_rate(&receipt, &short), | ||
| Err(ProviderReceiptError::InvalidReceiptPayload) | ||
| ); | ||
| assert_eq!( | ||
| refuse_source_text_in_receipt(), | ||
| Err(ProviderReceiptError::SourceTextNotDisclosable) | ||
| ); | ||
| assert_eq!( | ||
| refuse_source_identity_in_receipt(), | ||
| Err(ProviderReceiptError::SourceIdentityNotDisclosable) | ||
| ); | ||
| assert_eq!( | ||
| refuse_blanket_mask_as_disclosure(), | ||
| Err(ProviderReceiptError::BlanketMaskIsNotAuthorization) | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| //! Integration contract for the `provider_receipt` package identity. | ||
|
|
||
| #[test] | ||
| fn package_identity_is_stable() { | ||
| let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); | ||
| assert_eq!(observed, "provider_receipt"); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| //! Provider receipts cannot carry source text, identity, or a blanket mask. | ||
|
|
||
| use provider_receipt::{ | ||
| ProviderReceipt, ProviderReceiptError, receipt_recovery_rate, | ||
| refuse_blanket_mask_as_disclosure, refuse_source_identity_in_receipt, | ||
| refuse_source_text_in_receipt, | ||
| }; | ||
|
|
||
| fn receipt(purpose: u16, fields: &[u16]) -> ProviderReceipt { | ||
| ProviderReceipt::new(purpose, fields).expect("receipt") | ||
| } | ||
|
|
||
| #[test] | ||
| fn source_text_identity_and_blanket_mask_cannot_enter_a_receipt() { | ||
| assert_eq!( | ||
| refuse_source_text_in_receipt(), | ||
| Err(ProviderReceiptError::SourceTextNotDisclosable) | ||
| ); | ||
| assert_eq!( | ||
| refuse_source_identity_in_receipt(), | ||
| Err(ProviderReceiptError::SourceIdentityNotDisclosable) | ||
| ); | ||
| assert_eq!( | ||
| refuse_blanket_mask_as_disclosure(), | ||
| Err(ProviderReceiptError::BlanketMaskIsNotAuthorization) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn recovered_field_codes_match_known_truth_better_than_a_collapsed_set() { | ||
| let truth = receipt(7, &[1, 2, 3]); | ||
| let recovered = receipt(7, &[1, 2, 3]); | ||
| let collapsed = receipt(7, &[1, 1, 1]); | ||
| let recovered_rate = receipt_recovery_rate(&truth, &recovered).expect("recovered"); | ||
| let collapsed_rate = receipt_recovery_rate(&truth, &collapsed).expect("collapsed"); | ||
| let expected = { | ||
| let mut matches = 0_u32; | ||
| for (truth_field, decided_field) in truth.field_codes().iter().zip(recovered.field_codes()) | ||
| { | ||
| if truth_field == decided_field { | ||
| matches += 1; | ||
| } | ||
| } | ||
| f64::from(matches) / f64::from(u32::try_from(truth.field_codes().len()).expect("len")) | ||
| }; | ||
| assert!((recovered_rate - expected).abs() < f64::EPSILON); | ||
| assert!(recovered_rate > collapsed_rate); | ||
| } | ||
|
|
||
| #[test] | ||
| fn empty_or_mismatched_receipt_payloads_fail_closed() { | ||
| assert_eq!( | ||
| ProviderReceipt::new(7, &[]), | ||
| Err(ProviderReceiptError::InvalidReceiptPayload) | ||
| ); | ||
| let truth = receipt(7, &[1, 2]); | ||
| let short = receipt(7, &[1]); | ||
| assert_eq!( | ||
| receipt_recovery_rate(&truth, &short), | ||
| Err(ProviderReceiptError::InvalidReceiptPayload) | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📝 Info: Division-by-zero is structurally prevented
receipt_recovery_ratedivides bytruth.field_codes.len()(receipt.rs). This can never be zero becauseProviderReceipt::newrejects empty field-code slices (receipt.rs) and the struct fields are private, so a receipt cannot be constructed with an emptyfield_codes. No bug here.Was this helpful? React with 👍 or 👎 to provide feedback.