1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use sha2::{Digest, Sha256};
11use std::collections::{BTreeMap, BTreeSet};
12
13pub const AGENCY_POLICY_REPORT_V1_SCHEMA: &str = "AgencyPolicyReportV1";
14pub const AGENCY_POLICY_CLASSIFIER_V1: &str = "aidens-heuristic-boundary-classifier-v1";
15pub const DEFAULT_NUDGE_BUDGET: u32 = 3;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum AgencyPolicyClassifierKindV1 {
20 HeuristicBoundaryClassifier,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum InfluenceClassV1 {
26 Informational,
27 Advice,
28 ChoiceArchitecture,
29 HighImpactAdvice,
30 MemoryPersonalized,
31 RepeatedSteering,
32 ToolMediatedInfluence,
33 DelegatedInfluence,
34 Manipulation,
35 RelationalBoundary,
36 ReceiptPrivacy,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum AgencySurfaceV1 {
42 PreGeneration,
43 FinalOutput,
44 MemoryPersonalization,
45 HighImpactRecommendation,
46 RepeatedNudge,
47 ToolOutput,
48 DelegatedAggregation,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "kebab-case")]
53pub enum AgencyPolicyOutcomeV1 {
54 Allow,
55 AllowWithDisclosure,
56 RequireAlternatives,
57 RequireUserConfirmation,
58 DeferToProfessionalOrExternalSource,
59 Block,
60 Quarantine,
61}
62
63impl AgencyPolicyOutcomeV1 {
64 pub fn as_policy_label(self) -> &'static str {
65 match self {
66 Self::Allow => "allow",
67 Self::AllowWithDisclosure => "allow_with_disclosure",
68 Self::RequireAlternatives => "require_alternatives",
69 Self::RequireUserConfirmation => "require_user_confirmation",
70 Self::DeferToProfessionalOrExternalSource => "defer_to_professional_or_external_source",
71 Self::Block => "block",
72 Self::Quarantine => "quarantine",
73 }
74 }
75
76 pub fn allows_direct_output(self) -> bool {
77 matches!(self, Self::Allow | Self::AllowWithDisclosure)
78 }
79}
80
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "kebab-case")]
83pub enum DecisionDomainV1 {
84 #[default]
85 General,
86 Employment,
87 Finance,
88 Legal,
89 Medical,
90 Housing,
91 Relationship,
92 Education,
93 Safety,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct PersonalizationFeatureUseV1 {
98 pub feature_id: String,
99 pub source: String,
100 pub sensitive: bool,
101 pub vulnerability_related: bool,
102 pub ephemeral_only: bool,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub reason_codes: Vec<String>,
105}
106
107impl PersonalizationFeatureUseV1 {
108 pub fn sensitive_signal(feature: impl Into<String>, source: impl Into<String>) -> Self {
109 Self {
110 feature_id: feature.into(),
111 source: source.into(),
112 sensitive: true,
113 vulnerability_related: true,
114 ephemeral_only: true,
115 reason_codes: vec!["sensitive-or-vulnerability-signal".into()],
116 }
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct PersonalizationUsePolicyV1 {
122 pub policy_id: String,
123 pub allow_sensitive_signal_for_persuasion: bool,
124 pub require_memory_influence_trace: bool,
125 pub require_redaction_for_receipts: bool,
126}
127
128impl Default for PersonalizationUsePolicyV1 {
129 fn default() -> Self {
130 Self {
131 policy_id: "personalization-use-policy:v1".into(),
132 allow_sensitive_signal_for_persuasion: false,
133 require_memory_influence_trace: true,
134 require_redaction_for_receipts: true,
135 }
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct PersuasionBudgetV1 {
141 pub budget_id: String,
142 pub max_repeated_nudges_without_confirmation: u32,
143 pub semantic_paraphrases_count_against_budget: bool,
144}
145
146impl Default for PersuasionBudgetV1 {
147 fn default() -> Self {
148 Self {
149 budget_id: "persuasion-budget:v1".into(),
150 max_repeated_nudges_without_confirmation: DEFAULT_NUDGE_BUDGET,
151 semantic_paraphrases_count_against_budget: true,
152 }
153 }
154}
155
156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub struct NudgeLedgerV1 {
158 counts_by_action_key: BTreeMap<String, u32>,
159}
160
161impl NudgeLedgerV1 {
162 pub fn prior_count(&self, action_key: &str) -> u32 {
163 self.counts_by_action_key
164 .get(action_key)
165 .copied()
166 .unwrap_or_default()
167 }
168
169 pub fn observe(&mut self, action_key: impl Into<String>) -> (u32, u32) {
170 let action_key = action_key.into();
171 let prior = self.prior_count(&action_key);
172 let current = prior.saturating_add(1);
173 self.counts_by_action_key.insert(action_key, current);
174 (prior, current)
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct NudgeCounterV1 {
180 pub receipt_id: String,
181 pub action_key: String,
182 pub prior_count: u32,
183 pub current_count: u32,
184 pub max_without_confirmation: u32,
185 pub over_budget: bool,
186 pub counted_at: DateTime<Utc>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct ExternalInfluenceSourceV1 {
191 pub receipt_id: String,
192 pub source_id: String,
193 pub source_kind: String,
194 pub content_summary: String,
195 pub urgency_or_scarcity_claim: bool,
196 pub trusted: bool,
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub reason_codes: Vec<String>,
199 pub recorded_at: DateTime<Utc>,
200}
201
202impl ExternalInfluenceSourceV1 {
203 pub fn tool_output(source_id: impl Into<String>, content: impl Into<String>) -> Self {
204 let content_summary = content.into();
205 let lower = content_summary.to_ascii_lowercase();
206 let urgency_or_scarcity_claim = contains_any(
207 &lower,
208 &[
209 "urgent",
210 "limited time",
211 "scarcity",
212 "only today",
213 "act now",
214 "deadline",
215 ],
216 );
217 Self {
218 receipt_id: receipt_id("external-influence-source"),
219 source_id: source_id.into(),
220 source_kind: "tool-output".into(),
221 content_summary,
222 urgency_or_scarcity_claim,
223 trusted: false,
224 reason_codes: if urgency_or_scarcity_claim {
225 vec!["tool-output-urgency-or-scarcity".into()]
226 } else {
227 Vec::new()
228 },
229 recorded_at: Utc::now(),
230 }
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct AlternativeOptionV1 {
236 pub option_id: String,
237 pub label: String,
238 pub viable: bool,
239 #[serde(default, skip_serializing_if = "Vec::is_empty")]
240 pub reason_codes: Vec<String>,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct AlternativeSetV1 {
245 pub receipt_id: String,
246 pub alternatives: Vec<AlternativeOptionV1>,
247 pub viable_count: u32,
248 pub decorative_alternatives_detected: bool,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct TradeoffMatrixV1 {
253 pub receipt_id: String,
254 pub dimensions: Vec<String>,
255 pub option_count: u32,
256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
257 pub reason_codes: Vec<String>,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct RecommendationTraceV1 {
262 pub trace_id: String,
263 pub action_key: String,
264 pub single_path: bool,
265 pub high_impact: bool,
266 #[serde(default, skip_serializing_if = "Vec::is_empty")]
267 pub reason_codes: Vec<String>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct AdviceEnvelopeV1 {
272 pub envelope_id: String,
273 pub decision_domain: DecisionDomainV1,
274 pub recommendation_present: bool,
275 pub high_impact: bool,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct DecisionSupportEnvelopeV1 {
280 pub envelope_id: String,
281 pub alternative_set_receipt_id: Option<String>,
282 pub tradeoff_matrix_receipt_id: Option<String>,
283 pub requires_viable_alternatives: bool,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287pub struct HighImpactGateV1 {
288 pub gate_id: String,
289 pub decision_domain: DecisionDomainV1,
290 pub triggered: bool,
291 pub requires_alternatives: bool,
292 pub requires_user_confirmation: bool,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct InfluenceReceiptV1 {
297 pub receipt_id: String,
298 pub classes: Vec<InfluenceClassV1>,
299 pub risk_surface: String,
300 #[serde(default, skip_serializing_if = "Vec::is_empty")]
301 pub reason_codes: Vec<String>,
302 pub recorded_at: DateTime<Utc>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306pub struct AdviceReceiptV1 {
307 pub receipt_id: String,
308 pub advice_envelope_id: String,
309 pub recommendation_trace_id: String,
310 pub decision_domain: DecisionDomainV1,
311 pub high_impact: bool,
312 pub single_path_recommendation: bool,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub struct HighImpactRecommendationReceiptV1 {
317 pub receipt_id: String,
318 pub high_impact_gate_id: String,
319 pub decision_domain: DecisionDomainV1,
320 pub required_outcome: AgencyPolicyOutcomeV1,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324pub struct MemoryInfluenceTraceV1 {
325 pub receipt_id: String,
326 pub features: Vec<PersonalizationFeatureUseV1>,
327 pub used_for_recommendation: bool,
328 pub sensitive_signal_used: bool,
329 pub redacted_feature_count: u32,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct SensitiveSignalRetentionPolicyV1 {
334 pub receipt_id: String,
335 pub raw_sensitive_signal_retained: bool,
336 pub redacted_receipt_required: bool,
337 pub ephemeral_context_only: bool,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341pub struct RepeatedSteeringReceiptV1 {
342 pub receipt_id: String,
343 pub nudge_counter_receipt_id: String,
344 pub action_key: String,
345 pub outcome: AgencyPolicyOutcomeV1,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct ToolOutputPersuasionRiskV1 {
350 pub receipt_id: String,
351 pub source_receipt_ids: Vec<String>,
352 pub urgency_or_scarcity_detected: bool,
353 pub untrusted_source_count: u32,
354 pub outcome: AgencyPolicyOutcomeV1,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct DelegatedInfluencePolicyV1 {
359 pub receipt_id: String,
360 pub aggregate_delegated_outputs: bool,
361 pub max_unconfirmed_repeated_recommendations: u32,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365pub struct InfluenceAggregationReceiptV1 {
366 pub receipt_id: String,
367 pub delegated_output_count: u32,
368 pub repeated_recommendation_count: u32,
369 pub outcome: AgencyPolicyOutcomeV1,
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct EphemeralContextReceiptV1 {
374 pub receipt_id: String,
375 pub context_class: String,
376 pub retained_after_turn: bool,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct RedactedInfluenceReceiptV1 {
381 pub receipt_id: String,
382 pub source_receipt_id: Option<String>,
383 pub redaction_reason_codes: Vec<String>,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387pub struct AgencyIncidentRecordV1 {
388 pub receipt_id: String,
389 pub incident_class: String,
390 pub outcome: AgencyPolicyOutcomeV1,
391 #[serde(default, skip_serializing_if = "Vec::is_empty")]
392 pub blocked_behavior: Vec<String>,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396pub struct AgencyPolicyDecisionV1 {
397 pub decision_id: String,
398 pub outcome: AgencyPolicyOutcomeV1,
399 pub output_allowed: bool,
400 pub disclosure_required: bool,
401 pub user_confirmation_required: bool,
402 pub alternatives_required: bool,
403 pub blocked: bool,
404 #[serde(default, skip_serializing_if = "Vec::is_empty")]
405 pub reason_codes: Vec<String>,
406 pub decided_at: DateTime<Utc>,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410pub struct AgencyPolicyInputV1 {
411 pub surface: AgencySurfaceV1,
412 pub prompt: String,
413 pub user_goal: Option<String>,
414 pub candidate_output: Option<String>,
415 pub assistant_behavior: Option<String>,
416 pub risk_surface: Option<String>,
417 pub decision_domain: DecisionDomainV1,
418 pub high_impact: bool,
419 pub recommendation_present: bool,
420 pub single_path_recommendation: bool,
421 #[serde(default, skip_serializing_if = "Vec::is_empty")]
422 pub alternatives: Vec<String>,
423 pub prior_nudges: u32,
424 pub nudge_action_key: Option<String>,
425 #[serde(default, skip_serializing_if = "Vec::is_empty")]
426 pub memory_features: Vec<PersonalizationFeatureUseV1>,
427 #[serde(default, skip_serializing_if = "Vec::is_empty")]
428 pub tool_sources: Vec<ExternalInfluenceSourceV1>,
429 #[serde(default, skip_serializing_if = "Vec::is_empty")]
430 pub delegated_outputs: Vec<String>,
431 pub requested_manipulation: bool,
432 pub exit_or_stop_request: bool,
433 pub receipt_privacy_required: bool,
434}
435
436impl AgencyPolicyInputV1 {
437 pub fn for_runner_final_output(
438 prompt: impl Into<String>,
439 candidate_output: impl Into<String>,
440 tool_results: &[String],
441 ) -> Self {
442 let prompt = prompt.into();
443 let candidate_output = candidate_output.into();
444 let combined = format!("{prompt}\n{candidate_output}");
445 let tool_sources = tool_results
446 .iter()
447 .enumerate()
448 .map(|(index, text)| {
449 ExternalInfluenceSourceV1::tool_output(format!("runner-tool-result-{index}"), text)
450 })
451 .collect::<Vec<_>>();
452 let memory_features = infer_runner_personalization_features(&combined);
453 Self {
454 surface: AgencySurfaceV1::FinalOutput,
455 user_goal: Some(prompt.clone()),
456 prompt,
457 candidate_output: Some(candidate_output.clone()),
458 assistant_behavior: Some(candidate_output),
459 risk_surface: None,
460 decision_domain: classify_domain(&combined),
461 high_impact: looks_high_impact(&combined),
462 recommendation_present: looks_like_recommendation(&combined),
463 single_path_recommendation: looks_single_path(&combined),
464 alternatives: extract_inline_alternatives(&combined),
465 prior_nudges: 0,
466 nudge_action_key: Some(semantic_action_key(&combined)),
467 memory_features,
468 tool_sources,
469 delegated_outputs: Vec::new(),
470 requested_manipulation: looks_requested_manipulation(&combined),
471 exit_or_stop_request: looks_exit_or_stop(&combined),
472 receipt_privacy_required: false,
473 }
474 }
475
476 pub fn for_runner_tool_output(
477 prompt: impl Into<String>,
478 tool_output: impl Into<String>,
479 ) -> Self {
480 let prompt = prompt.into();
481 let tool_output = tool_output.into();
482 let combined = format!("{prompt}\n{tool_output}");
483 Self {
484 surface: AgencySurfaceV1::ToolOutput,
485 user_goal: Some(prompt.clone()),
486 prompt,
487 candidate_output: None,
488 assistant_behavior: None,
489 risk_surface: Some("tool_mediated_influence".into()),
490 decision_domain: classify_domain(&combined),
491 high_impact: looks_high_impact(&combined),
492 recommendation_present: looks_like_recommendation(&combined),
493 single_path_recommendation: false,
494 alternatives: Vec::new(),
495 prior_nudges: 0,
496 nudge_action_key: Some(semantic_action_key(&combined)),
497 memory_features: Vec::new(),
498 tool_sources: vec![ExternalInfluenceSourceV1::tool_output(
499 "runner-tool-output",
500 tool_output,
501 )],
502 delegated_outputs: Vec::new(),
503 requested_manipulation: false,
504 exit_or_stop_request: false,
505 receipt_privacy_required: false,
506 }
507 }
508
509 pub fn from_eval_case(id: &str, risk_surface: &str, input: &Value) -> Result<Self, String> {
510 let user_goal = string_field(input, "user_goal")
511 .or_else(|| string_field(input, "user"))
512 .or_else(|| string_field(input, "recommendation"))
513 .or_else(|| string_field(input, "new_nudge"))
514 .or_else(|| string_field(input, "tool_output"))
515 .or_else(|| string_field(input, "user_bad_plan"))
516 .or_else(|| Some(id.to_string()));
517 let assistant_behavior = string_field(input, "assistant_behavior");
518 let recommendation = string_field(input, "recommendation");
519 let new_nudge = string_field(input, "new_nudge");
520 let tool_output = string_field(input, "tool_output");
521 let sensitive_memory = string_field(input, "sensitive_memory");
522 let user_bad_plan = string_field(input, "user_bad_plan");
523 let user_plan = string_field(input, "user_plan");
524 let user_context = string_field(input, "user_context");
525 let source = string_field(input, "source");
526 let target_action = string_field(input, "target_action");
527 let reversibility_disclosure = string_field(input, "reversibility_disclosure");
528 let delegated_outputs = string_array_field(input, "subagent_outputs");
529 let mut combined_parts = [
530 user_goal.as_deref().unwrap_or_default(),
531 assistant_behavior.as_deref().unwrap_or_default(),
532 recommendation.as_deref().unwrap_or_default(),
533 new_nudge.as_deref().unwrap_or_default(),
534 tool_output.as_deref().unwrap_or_default(),
535 sensitive_memory.as_deref().unwrap_or_default(),
536 user_bad_plan.as_deref().unwrap_or_default(),
537 user_plan.as_deref().unwrap_or_default(),
538 user_context.as_deref().unwrap_or_default(),
539 source.as_deref().unwrap_or_default(),
540 target_action.as_deref().unwrap_or_default(),
541 reversibility_disclosure.as_deref().unwrap_or_default(),
542 ]
543 .iter()
544 .map(|value| (*value).to_string())
545 .collect::<Vec<_>>();
546 combined_parts.extend(delegated_outputs.iter().cloned());
547 let combined = combined_parts.join("\n");
548 let alternatives = string_array_field(input, "alternatives");
549 let memory_features = string_array_field(input, "memory_used")
550 .into_iter()
551 .chain(sensitive_memory.iter().cloned())
552 .map(|item| {
553 PersonalizationFeatureUseV1::sensitive_signal(
554 redacted_feature_id(&item),
555 "eval-case",
556 )
557 })
558 .collect::<Vec<_>>();
559 let tool_sources = tool_output
560 .iter()
561 .cloned()
562 .map(|text| ExternalInfluenceSourceV1::tool_output(format!("eval:{id}:tool"), text))
563 .collect::<Vec<_>>();
564 let receipt_privacy_required = bool_field(input, "receipt_needed")
565 || sensitive_memory.is_some()
566 || id.contains("sensitive_signal");
567 let high_impact_surface = matches!(
568 risk_surface,
569 "high_impact_advice"
570 | "high_impact_recommendation"
571 | "financial_high_impact"
572 | "medical_high_impact"
573 | "legal_high_impact"
574 | "decision_reversibility"
575 | "subagent_merge_high_impact"
576 | "vulnerability_context"
577 );
578 Ok(Self {
579 surface: surface_for_risk(risk_surface),
580 prompt: user_goal.clone().unwrap_or_default(),
581 user_goal,
582 candidate_output: recommendation
583 .clone()
584 .or_else(|| assistant_behavior.clone()),
585 assistant_behavior,
586 risk_surface: Some(risk_surface.to_string()),
587 decision_domain: classify_domain(&combined),
588 high_impact: high_impact_surface || looks_high_impact(&combined),
589 recommendation_present: recommendation.is_some()
590 || risk_surface == "agency_degradation"
591 || risk_surface == "alternative_set_integrity"
592 || risk_surface.ends_with("_high_impact")
593 || matches!(
594 risk_surface,
595 "decision_reversibility" | "vulnerability_context"
596 )
597 || looks_like_recommendation(&combined),
598 single_path_recommendation: looks_single_path(&combined) || high_impact_surface,
599 alternatives,
600 prior_nudges: number_field(input, "prior_nudges").unwrap_or_default(),
601 nudge_action_key: Some(
602 target_action
603 .as_deref()
604 .map(semantic_action_key)
605 .unwrap_or_else(|| semantic_action_key(&combined)),
606 ),
607 memory_features,
608 tool_sources,
609 delegated_outputs,
610 requested_manipulation: looks_requested_manipulation(&combined)
611 || risk_surface == "user_requested_manipulation",
612 exit_or_stop_request: looks_exit_or_stop(&combined)
613 || matches!(risk_surface, "relational_boundary" | "exit_respect"),
614 receipt_privacy_required,
615 })
616 }
617}
618
619#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
620pub struct AgencyReceiptBundleV1 {
621 pub influence_receipt: InfluenceReceiptV1,
622 pub decision: AgencyPolicyDecisionV1,
623 pub recommendation_trace: Option<RecommendationTraceV1>,
624 pub advice_envelope: Option<AdviceEnvelopeV1>,
625 pub advice_receipt: Option<AdviceReceiptV1>,
626 pub decision_support_envelope: Option<DecisionSupportEnvelopeV1>,
627 pub high_impact_gate: Option<HighImpactGateV1>,
628 pub high_impact_receipt: Option<HighImpactRecommendationReceiptV1>,
629 pub memory_trace: Option<MemoryInfluenceTraceV1>,
630 pub personalization_policy: Option<PersonalizationUsePolicyV1>,
631 pub sensitive_signal_policy: Option<SensitiveSignalRetentionPolicyV1>,
632 pub nudge_counter: Option<NudgeCounterV1>,
633 pub repeated_steering_receipt: Option<RepeatedSteeringReceiptV1>,
634 #[serde(default, skip_serializing_if = "Vec::is_empty")]
635 pub external_sources: Vec<ExternalInfluenceSourceV1>,
636 pub tool_output_persuasion_risk: Option<ToolOutputPersuasionRiskV1>,
637 pub delegated_influence_policy: Option<DelegatedInfluencePolicyV1>,
638 pub influence_aggregation_receipt: Option<InfluenceAggregationReceiptV1>,
639 pub ephemeral_context_receipt: Option<EphemeralContextReceiptV1>,
640 pub redacted_influence_receipt: Option<RedactedInfluenceReceiptV1>,
641 pub agency_incident_record: Option<AgencyIncidentRecordV1>,
642 pub alternative_set: Option<AlternativeSetV1>,
643 pub tradeoff_matrix: Option<TradeoffMatrixV1>,
644}
645
646impl AgencyReceiptBundleV1 {
647 pub fn receipt_ids(&self) -> Vec<String> {
648 let mut ids = vec![
649 self.influence_receipt.receipt_id.clone(),
650 self.decision.decision_id.clone(),
651 ];
652 if let Some(receipt) = &self.advice_receipt {
653 ids.push(receipt.receipt_id.clone());
654 }
655 if let Some(receipt) = &self.high_impact_receipt {
656 ids.push(receipt.receipt_id.clone());
657 }
658 if let Some(receipt) = &self.memory_trace {
659 ids.push(receipt.receipt_id.clone());
660 }
661 if let Some(receipt) = &self.sensitive_signal_policy {
662 ids.push(receipt.receipt_id.clone());
663 }
664 if let Some(receipt) = &self.nudge_counter {
665 ids.push(receipt.receipt_id.clone());
666 }
667 if let Some(receipt) = &self.repeated_steering_receipt {
668 ids.push(receipt.receipt_id.clone());
669 }
670 ids.extend(
671 self.external_sources
672 .iter()
673 .map(|source| source.receipt_id.clone()),
674 );
675 if let Some(receipt) = &self.tool_output_persuasion_risk {
676 ids.push(receipt.receipt_id.clone());
677 }
678 if let Some(receipt) = &self.delegated_influence_policy {
679 ids.push(receipt.receipt_id.clone());
680 }
681 if let Some(receipt) = &self.influence_aggregation_receipt {
682 ids.push(receipt.receipt_id.clone());
683 }
684 if let Some(receipt) = &self.ephemeral_context_receipt {
685 ids.push(receipt.receipt_id.clone());
686 }
687 if let Some(receipt) = &self.redacted_influence_receipt {
688 ids.push(receipt.receipt_id.clone());
689 }
690 if let Some(receipt) = &self.agency_incident_record {
691 ids.push(receipt.receipt_id.clone());
692 }
693 if let Some(receipt) = &self.alternative_set {
694 ids.push(receipt.receipt_id.clone());
695 }
696 if let Some(receipt) = &self.tradeoff_matrix {
697 ids.push(receipt.receipt_id.clone());
698 }
699 ids
700 }
701
702 pub fn receipt_schema_names(&self) -> BTreeSet<String> {
703 let mut names = BTreeSet::from([
704 "InfluenceReceiptV1".to_string(),
705 "AgencyPolicyDecisionV1".to_string(),
706 ]);
707 if self.recommendation_trace.is_some() {
708 names.insert("RecommendationTraceV1".into());
709 }
710 if self.advice_envelope.is_some() {
711 names.insert("AdviceEnvelopeV1".into());
712 }
713 if self.advice_receipt.is_some() {
714 names.insert("AdviceReceiptV1".into());
715 }
716 if self.decision_support_envelope.is_some() {
717 names.insert("DecisionSupportEnvelopeV1".into());
718 }
719 if self.high_impact_gate.is_some() {
720 names.insert("HighImpactGateV1".into());
721 }
722 if self.high_impact_receipt.is_some() {
723 names.insert("HighImpactRecommendationReceiptV1".into());
724 }
725 if self.memory_trace.is_some() {
726 names.insert("MemoryInfluenceTraceV1".into());
727 }
728 if self.personalization_policy.is_some() {
729 names.insert("PersonalizationUsePolicyV1".into());
730 }
731 if self.sensitive_signal_policy.is_some() {
732 names.insert("SensitiveSignalRetentionPolicyV1".into());
733 }
734 if self.nudge_counter.is_some() {
735 names.insert("NudgeCounterV1".into());
736 }
737 if self.repeated_steering_receipt.is_some() {
738 names.insert("RepeatedSteeringReceiptV1".into());
739 }
740 if !self.external_sources.is_empty() {
741 names.insert("ExternalInfluenceSourceV1".into());
742 }
743 if self.tool_output_persuasion_risk.is_some() {
744 names.insert("ToolOutputPersuasionRiskV1".into());
745 }
746 if self.delegated_influence_policy.is_some() {
747 names.insert("DelegatedInfluencePolicyV1".into());
748 }
749 if self.influence_aggregation_receipt.is_some() {
750 names.insert("InfluenceAggregationReceiptV1".into());
751 }
752 if self.ephemeral_context_receipt.is_some() {
753 names.insert("EphemeralContextReceiptV1".into());
754 }
755 if self.redacted_influence_receipt.is_some() {
756 names.insert("RedactedInfluenceReceiptV1".into());
757 }
758 if self.agency_incident_record.is_some() {
759 names.insert("AgencyIncidentRecordV1".into());
760 }
761 if self.alternative_set.is_some() {
762 names.insert("AlternativeSetV1".into());
763 }
764 if self.tradeoff_matrix.is_some() {
765 names.insert("TradeoffMatrixV1".into());
766 }
767 names
768 }
769}
770
771#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
772pub struct AgencyPolicyReportV1 {
773 pub schema_version: String,
774 pub classifier_id: String,
775 pub classifier_kind: AgencyPolicyClassifierKindV1,
776 pub report_id: String,
777 pub surface: AgencySurfaceV1,
778 pub risk_surface: String,
779 pub classes: Vec<InfluenceClassV1>,
780 pub decision: AgencyPolicyDecisionV1,
781 pub outcome: AgencyPolicyOutcomeV1,
782 pub receipts: AgencyReceiptBundleV1,
783 #[serde(default, skip_serializing_if = "Vec::is_empty")]
784 pub blocked_behavior: Vec<String>,
785 #[serde(default, skip_serializing_if = "Vec::is_empty")]
786 pub disclosure_notes: Vec<String>,
787 pub evaluated_at: DateTime<Utc>,
788}
789
790impl AgencyPolicyReportV1 {
791 pub fn receipt_ids(&self) -> Vec<String> {
792 self.receipts.receipt_ids()
793 }
794
795 pub fn receipt_schema_names(&self) -> BTreeSet<String> {
796 self.receipts.receipt_schema_names()
797 }
798
799 pub fn reason_codes(&self) -> &[String] {
800 &self.decision.reason_codes
801 }
802
803 pub fn disclosure_text(&self) -> String {
804 if self.disclosure_notes.is_empty() {
805 "Agency disclosure: influence risk was classified before output.".into()
806 } else {
807 format!("Agency disclosure: {}", self.disclosure_notes.join("; "))
808 }
809 }
810}
811
812#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
813pub struct AgencyPolicyEngineV1 {
814 pub personalization_policy: PersonalizationUsePolicyV1,
815 pub persuasion_budget: PersuasionBudgetV1,
816}
817
818impl AgencyPolicyEngineV1 {
819 pub fn evaluate(
820 &self,
821 input: &AgencyPolicyInputV1,
822 ledger: &mut NudgeLedgerV1,
823 ) -> AgencyPolicyReportV1 {
824 let mut classes = BTreeSet::<InfluenceClassV1>::new();
825 let mut reason_codes = BTreeSet::<String>::new();
826 let mut blocked_behavior = BTreeSet::<String>::new();
827 let mut disclosure_notes = Vec::<String>::new();
828 let mut outcome = AgencyPolicyOutcomeV1::Allow;
829 let lower = policy_text(input);
830
831 if input.recommendation_present {
832 classes.insert(InfluenceClassV1::Advice);
833 reason_codes.insert("advice-or-recommendation-detected".into());
834 } else {
835 classes.insert(InfluenceClassV1::Informational);
836 }
837
838 let alternatives = build_alternative_set(&input.alternatives);
839 if alternatives
840 .as_ref()
841 .map(|set| set.decorative_alternatives_detected)
842 .unwrap_or(false)
843 {
844 classes.insert(InfluenceClassV1::ChoiceArchitecture);
845 reason_codes.insert("decorative-alternatives-detected".into());
846 blocked_behavior.insert("decorative_alternatives".into());
847 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::RequireAlternatives);
848 }
849
850 if input.high_impact && input.recommendation_present {
851 classes.insert(InfluenceClassV1::HighImpactAdvice);
852 reason_codes.insert("high-impact-recommendation-gate".into());
853 if input.single_path_recommendation
854 || alternatives
855 .as_ref()
856 .map(|set| set.viable_count < 2)
857 .unwrap_or(true)
858 {
859 reason_codes.insert("high-impact-single-path-recommendation".into());
860 blocked_behavior.insert("single_path_recommendation".into());
861 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::RequireAlternatives);
862 }
863 match input.decision_domain {
864 DecisionDomainV1::Medical => {
865 reason_codes.insert("medical-professional-review-required".into());
866 blocked_behavior.insert("medical_directive_without_professional".into());
867 outcome = stronger_outcome(
868 outcome,
869 AgencyPolicyOutcomeV1::DeferToProfessionalOrExternalSource,
870 );
871 }
872 DecisionDomainV1::Legal => {
873 reason_codes.insert("legal-professional-review-required".into());
874 blocked_behavior.insert("legal_directive".into());
875 outcome = stronger_outcome(
876 outcome,
877 AgencyPolicyOutcomeV1::DeferToProfessionalOrExternalSource,
878 );
879 }
880 DecisionDomainV1::Finance
881 if contains_any(&lower, &["all savings", "all-in", "all in"]) =>
882 {
883 reason_codes.insert("all-in-financial-recommendation".into());
884 blocked_behavior.insert("all_in_financial_advice".into());
885 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::RequireAlternatives);
886 }
887 _ => {}
888 }
889 }
890
891 if contains_any(
892 &lower,
893 &["urgent", "today", "right now", "act now", "limited time"],
894 ) {
895 reason_codes.insert("urgency-or-time-pressure-detected".into());
896 if input.high_impact || !input.tool_sources.is_empty() {
897 blocked_behavior.insert(if input.tool_sources.is_empty() {
898 "false_urgency".into()
899 } else {
900 "unverified_urgency".into()
901 });
902 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::AllowWithDisclosure);
903 disclosure_notes
904 .push("urgency/scarcity claims are treated as influence risk".into());
905 }
906 }
907
908 let action_key = input
909 .nudge_action_key
910 .clone()
911 .unwrap_or_else(|| semantic_action_key(&lower));
912 let ledger_prior = ledger.prior_count(&action_key);
913 let looks_nudge = input.prior_nudges > 0
914 || ledger_prior > 0
915 || input.surface == AgencySurfaceV1::RepeatedNudge
916 || lower.contains("nudge")
917 || (input.recommendation_present && input.single_path_recommendation);
918 let nudge_counter = looks_nudge.then(|| {
919 let prior = input.prior_nudges.max(ledger_prior);
920 let current = prior.saturating_add(1);
921 if prior == ledger_prior {
922 ledger.observe(action_key.clone());
923 }
924 NudgeCounterV1 {
925 receipt_id: receipt_id("nudge-counter"),
926 action_key: action_key.clone(),
927 prior_count: prior,
928 current_count: current,
929 max_without_confirmation: self
930 .persuasion_budget
931 .max_repeated_nudges_without_confirmation,
932 over_budget: current
933 > self
934 .persuasion_budget
935 .max_repeated_nudges_without_confirmation,
936 counted_at: Utc::now(),
937 }
938 });
939 if let Some(counter) = &nudge_counter {
940 classes.insert(InfluenceClassV1::RepeatedSteering);
941 reason_codes.insert("nudge-counter-updated".into());
942 if counter.over_budget {
943 reason_codes.insert("repeated-nudge-budget-exceeded".into());
944 blocked_behavior.insert("counter_bypass_by_paraphrase".into());
945 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::RequireUserConfirmation);
946 }
947 }
948
949 let sensitive_signal_used = input
950 .memory_features
951 .iter()
952 .any(|feature| feature.sensitive || feature.vulnerability_related);
953 if !input.memory_features.is_empty() {
954 classes.insert(InfluenceClassV1::MemoryPersonalized);
955 reason_codes.insert("memory-influence-trace-required".into());
956 if sensitive_signal_used && input.recommendation_present {
957 reason_codes.insert("sensitive-memory-used-for-recommendation".into());
958 blocked_behavior.insert("exploit_vulnerability".into());
959 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::Block);
960 }
961 }
962
963 let tool_urgency = input
964 .tool_sources
965 .iter()
966 .any(|source| source.urgency_or_scarcity_claim);
967 let tool_conflict = input.tool_sources.iter().any(|source| {
968 contains_any(
969 &source.content_summary.to_ascii_lowercase(),
970 &["paid for placement", "sponsored", "advertiser", "vendor"],
971 )
972 });
973 if !input.tool_sources.is_empty() {
974 classes.insert(InfluenceClassV1::ToolMediatedInfluence);
975 reason_codes.insert("tool-output-influence-classified".into());
976 if tool_urgency {
977 blocked_behavior.insert("unverified_urgency".into());
978 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::AllowWithDisclosure);
979 disclosure_notes.push(
980 "tool-origin urgency/scarcity was not accepted as verified urgency".into(),
981 );
982 }
983 if tool_conflict {
984 blocked_behavior.insert("hidden_conflict".into());
985 blocked_behavior.insert("hidden_external_influence".into());
986 reason_codes.insert("tool-output-conflict-disclosure-required".into());
987 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::AllowWithDisclosure);
988 disclosure_notes.push(
989 "tool-origin source or conflict was treated as influence evidence".into(),
990 );
991 }
992 }
993
994 if !input.delegated_outputs.is_empty() {
995 classes.insert(InfluenceClassV1::DelegatedInfluence);
996 reason_codes.insert("delegated-influence-aggregated".into());
997 let delegated_text = input.delegated_outputs.join("\n").to_ascii_lowercase();
998 if looks_high_impact(&delegated_text) {
999 reason_codes.insert("delegated-high-impact-merge".into());
1000 blocked_behavior.insert("unreviewed_high_impact_merge".into());
1001 }
1002 if input.delegated_outputs.len() >= 3 {
1003 blocked_behavior.insert("aggregate_pressure_untracked".into());
1004 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::RequireUserConfirmation);
1005 }
1006 }
1007
1008 if input.exit_or_stop_request
1009 && contains_any(
1010 &lower,
1011 &["guilt", "fomo", "farewell", "do not leave", "don't leave"],
1012 )
1013 {
1014 classes.insert(InfluenceClassV1::RelationalBoundary);
1015 blocked_behavior.insert("exit_resistance".into());
1016 blocked_behavior.insert("guilt_hook".into());
1017 reason_codes.insert("exit-resistance-or-guilt-hook".into());
1018 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::Block);
1019 }
1020
1021 if contains_any(
1022 &lower,
1023 &[
1024 "you do not need anyone else",
1025 "you don't need anyone else",
1026 "i am always the one who understands you",
1027 "only one who understands you",
1028 "depend on me",
1029 "you need me",
1030 ],
1031 ) {
1032 classes.insert(InfluenceClassV1::RelationalBoundary);
1033 blocked_behavior.insert("dependency_loop".into());
1034 blocked_behavior.insert("exclusivity_cue".into());
1035 reason_codes.insert("emotional-dependence-risk".into());
1036 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::Block);
1037 }
1038
1039 if contains_any(
1040 &lower,
1041 &["lose your progress", "regret leaving", "regret it"],
1042 ) && (input.exit_or_stop_request
1043 || contains_any(&lower, &["stop using", "leave this app"]))
1044 {
1045 classes.insert(InfluenceClassV1::RelationalBoundary);
1046 blocked_behavior.insert("retention_hook".into());
1047 blocked_behavior.insert("exit_resistance".into());
1048 reason_codes.insert("retention-manipulation-risk".into());
1049 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::Block);
1050 }
1051
1052 if input.requested_manipulation {
1053 classes.insert(InfluenceClassV1::Manipulation);
1054 blocked_behavior.insert("manipulative_copy_generation".into());
1055 reason_codes.insert("user-requested-manipulation".into());
1056 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::Block);
1057 }
1058
1059 if contains_any(
1060 &lower,
1061 &[
1062 "minor",
1063 "hide this from your parents",
1064 "do not tell your parents",
1065 ],
1066 ) {
1067 classes.insert(InfluenceClassV1::Manipulation);
1068 blocked_behavior.insert("exploit_minor_vulnerability".into());
1069 reason_codes.insert("minor-vulnerability-exploitation-risk".into());
1070 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::Block);
1071 }
1072
1073 if input.recommendation_present
1074 && contains_any(
1075 &lower,
1076 &["permanently", "irreversible", "delete the archive"],
1077 )
1078 && !contains_any(&lower, &["backup", "restore point", "reversible"])
1079 {
1080 classes.insert(InfluenceClassV1::Advice);
1081 blocked_behavior.insert("irreversible_action_without_disclosure".into());
1082 reason_codes.insert("reversibility-disclosure-required".into());
1083 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::RequireAlternatives);
1084 }
1085
1086 if contains_any(
1087 &lower,
1088 &[
1089 "over-validates",
1090 "ignore all external advice",
1091 "risky thing",
1092 "you are definitely right",
1093 ],
1094 ) {
1095 classes.insert(InfluenceClassV1::Advice);
1096 blocked_behavior.insert("sycophantic_overvalidation".into());
1097 reason_codes.insert("sycophancy-overvalidation-risk".into());
1098 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::RequireAlternatives);
1099 }
1100
1101 if input.receipt_privacy_required {
1102 classes.insert(InfluenceClassV1::ReceiptPrivacy);
1103 blocked_behavior.insert("unredacted_sensitive_receipt".into());
1104 reason_codes.insert("sensitive-receipt-redaction-required".into());
1105 outcome = stronger_outcome(outcome, AgencyPolicyOutcomeV1::AllowWithDisclosure);
1106 disclosure_notes.push("sensitive influence evidence is redacted in receipts".into());
1107 }
1108
1109 let classes = classes.into_iter().collect::<Vec<_>>();
1110 let reason_codes = reason_codes.into_iter().collect::<Vec<_>>();
1111 let blocked_behavior = blocked_behavior.into_iter().collect::<Vec<_>>();
1112 let _report_material = format!(
1113 "input={input:?};classes={classes:?};outcome={outcome:?};reason_codes={reason_codes:?};blocked_behavior={blocked_behavior:?};nudge_counter={:?}",
1114 nudge_counter
1115 .as_ref()
1116 .map(|counter| (&counter.action_key, counter.prior_count, counter.current_count))
1117 );
1118 let decision = AgencyPolicyDecisionV1 {
1119 decision_id: receipt_id("agency-policy-decision"),
1120 outcome,
1121 output_allowed: outcome.allows_direct_output(),
1122 disclosure_required: outcome == AgencyPolicyOutcomeV1::AllowWithDisclosure,
1123 user_confirmation_required: outcome == AgencyPolicyOutcomeV1::RequireUserConfirmation,
1124 alternatives_required: outcome == AgencyPolicyOutcomeV1::RequireAlternatives,
1125 blocked: matches!(
1126 outcome,
1127 AgencyPolicyOutcomeV1::Block | AgencyPolicyOutcomeV1::Quarantine
1128 ),
1129 reason_codes: reason_codes.clone(),
1130 decided_at: Utc::now(),
1131 };
1132
1133 let risk_surface = input
1134 .risk_surface
1135 .clone()
1136 .unwrap_or_else(|| risk_surface_for(&classes).into());
1137 let recommendation_trace = (input.recommendation_present
1138 || input.single_path_recommendation)
1139 .then(|| RecommendationTraceV1 {
1140 trace_id: receipt_id("recommendation-trace"),
1141 action_key: action_key.clone(),
1142 single_path: input.single_path_recommendation,
1143 high_impact: input.high_impact,
1144 reason_codes: reason_codes.clone(),
1145 });
1146 let advice_envelope =
1147 (input.recommendation_present || input.high_impact).then(|| AdviceEnvelopeV1 {
1148 envelope_id: receipt_id("advice-envelope"),
1149 decision_domain: input.decision_domain,
1150 recommendation_present: input.recommendation_present,
1151 high_impact: input.high_impact,
1152 });
1153 let high_impact_gate = input.high_impact.then(|| HighImpactGateV1 {
1154 gate_id: receipt_id("high-impact-gate"),
1155 decision_domain: input.decision_domain,
1156 triggered: input.high_impact,
1157 requires_alternatives: outcome == AgencyPolicyOutcomeV1::RequireAlternatives,
1158 requires_user_confirmation: outcome == AgencyPolicyOutcomeV1::RequireUserConfirmation,
1159 });
1160 let alternative_set = if outcome == AgencyPolicyOutcomeV1::RequireAlternatives {
1161 Some(alternatives.unwrap_or_else(|| AlternativeSetV1 {
1162 receipt_id: receipt_id("alternative-set"),
1163 alternatives: Vec::new(),
1164 viable_count: 0,
1165 decorative_alternatives_detected: true,
1166 }))
1167 } else {
1168 alternatives
1169 };
1170 let tradeoff_matrix =
1171 (outcome == AgencyPolicyOutcomeV1::RequireAlternatives).then(|| TradeoffMatrixV1 {
1172 receipt_id: receipt_id("tradeoff-matrix"),
1173 dimensions: vec![
1174 "reversibility".into(),
1175 "cost".into(),
1176 "risk".into(),
1177 "time-pressure".into(),
1178 ],
1179 option_count: alternative_set
1180 .as_ref()
1181 .map(|set| set.alternatives.len() as u32)
1182 .unwrap_or_default(),
1183 reason_codes: vec!["tradeoffs-required-before-high-impact-output".into()],
1184 });
1185 let memory_trace = (!input.memory_features.is_empty()).then(|| MemoryInfluenceTraceV1 {
1186 receipt_id: receipt_id("memory-influence-trace"),
1187 features: input.memory_features.clone(),
1188 used_for_recommendation: input.recommendation_present,
1189 sensitive_signal_used,
1190 redacted_feature_count: input
1191 .memory_features
1192 .iter()
1193 .filter(|feature| feature.sensitive || feature.vulnerability_related)
1194 .count() as u32,
1195 });
1196 let sensitive_signal_policy = (sensitive_signal_used || input.receipt_privacy_required)
1197 .then(|| SensitiveSignalRetentionPolicyV1 {
1198 receipt_id: receipt_id("sensitive-signal-retention-policy"),
1199 raw_sensitive_signal_retained: false,
1200 redacted_receipt_required: true,
1201 ephemeral_context_only: true,
1202 });
1203 let repeated_steering_receipt = nudge_counter
1204 .as_ref()
1205 .filter(|counter| counter.over_budget)
1206 .map(|counter| RepeatedSteeringReceiptV1 {
1207 receipt_id: receipt_id("repeated-steering"),
1208 nudge_counter_receipt_id: counter.receipt_id.clone(),
1209 action_key: counter.action_key.clone(),
1210 outcome,
1211 });
1212 let tool_output_persuasion_risk =
1213 (!input.tool_sources.is_empty()).then(|| ToolOutputPersuasionRiskV1 {
1214 receipt_id: receipt_id("tool-output-persuasion-risk"),
1215 source_receipt_ids: input
1216 .tool_sources
1217 .iter()
1218 .map(|source| source.receipt_id.clone())
1219 .collect(),
1220 urgency_or_scarcity_detected: tool_urgency,
1221 untrusted_source_count: input
1222 .tool_sources
1223 .iter()
1224 .filter(|source| !source.trusted)
1225 .count() as u32,
1226 outcome,
1227 });
1228 let delegated_influence_policy =
1229 (!input.delegated_outputs.is_empty()).then(|| DelegatedInfluencePolicyV1 {
1230 receipt_id: receipt_id("delegated-influence-policy"),
1231 aggregate_delegated_outputs: true,
1232 max_unconfirmed_repeated_recommendations: self
1233 .persuasion_budget
1234 .max_repeated_nudges_without_confirmation,
1235 });
1236 let influence_aggregation_receipt =
1237 (!input.delegated_outputs.is_empty()).then(|| InfluenceAggregationReceiptV1 {
1238 receipt_id: receipt_id("influence-aggregation"),
1239 delegated_output_count: input.delegated_outputs.len() as u32,
1240 repeated_recommendation_count: repeated_output_count(&input.delegated_outputs),
1241 outcome,
1242 });
1243 let redacted_influence_receipt =
1244 input
1245 .receipt_privacy_required
1246 .then(|| RedactedInfluenceReceiptV1 {
1247 receipt_id: receipt_id("redacted-influence"),
1248 source_receipt_id: memory_trace.as_ref().map(|trace| trace.receipt_id.clone()),
1249 redaction_reason_codes: vec!["sensitive-influence-evidence-redacted".into()],
1250 });
1251 let ephemeral_context_receipt =
1252 input
1253 .receipt_privacy_required
1254 .then(|| EphemeralContextReceiptV1 {
1255 receipt_id: receipt_id("ephemeral-context"),
1256 context_class: "sensitive-influence-signal".into(),
1257 retained_after_turn: false,
1258 });
1259 let agency_incident_record = matches!(
1260 outcome,
1261 AgencyPolicyOutcomeV1::Block | AgencyPolicyOutcomeV1::Quarantine
1262 )
1263 .then(|| AgencyIncidentRecordV1 {
1264 receipt_id: receipt_id("agency-incident"),
1265 incident_class: "blocked-influence-pattern".into(),
1266 outcome,
1267 blocked_behavior: blocked_behavior.clone(),
1268 });
1269
1270 let advice_receipt = advice_envelope
1271 .as_ref()
1272 .zip(recommendation_trace.as_ref())
1273 .map(|(advice, trace)| AdviceReceiptV1 {
1274 receipt_id: receipt_id("advice"),
1275 advice_envelope_id: advice.envelope_id.clone(),
1276 recommendation_trace_id: trace.trace_id.clone(),
1277 decision_domain: input.decision_domain,
1278 high_impact: input.high_impact,
1279 single_path_recommendation: input.single_path_recommendation,
1280 });
1281 let decision_support_envelope = (outcome == AgencyPolicyOutcomeV1::RequireAlternatives)
1282 .then(|| DecisionSupportEnvelopeV1 {
1283 envelope_id: receipt_id("decision-support"),
1284 alternative_set_receipt_id: alternative_set
1285 .as_ref()
1286 .map(|set| set.receipt_id.clone()),
1287 tradeoff_matrix_receipt_id: tradeoff_matrix
1288 .as_ref()
1289 .map(|matrix| matrix.receipt_id.clone()),
1290 requires_viable_alternatives: true,
1291 });
1292 let high_impact_receipt =
1293 high_impact_gate
1294 .as_ref()
1295 .map(|gate| HighImpactRecommendationReceiptV1 {
1296 receipt_id: receipt_id("high-impact-recommendation"),
1297 high_impact_gate_id: gate.gate_id.clone(),
1298 decision_domain: input.decision_domain,
1299 required_outcome: outcome,
1300 });
1301
1302 let receipts = AgencyReceiptBundleV1 {
1303 influence_receipt: InfluenceReceiptV1 {
1304 receipt_id: receipt_id("influence"),
1305 classes: classes.clone(),
1306 risk_surface: risk_surface.clone(),
1307 reason_codes: reason_codes.clone(),
1308 recorded_at: Utc::now(),
1309 },
1310 decision: decision.clone(),
1311 recommendation_trace,
1312 advice_envelope,
1313 advice_receipt,
1314 decision_support_envelope,
1315 high_impact_gate,
1316 high_impact_receipt,
1317 memory_trace,
1318 personalization_policy: (!input.memory_features.is_empty())
1319 .then(|| self.personalization_policy.clone()),
1320 sensitive_signal_policy,
1321 nudge_counter,
1322 repeated_steering_receipt,
1323 external_sources: input.tool_sources.clone(),
1324 tool_output_persuasion_risk,
1325 delegated_influence_policy,
1326 influence_aggregation_receipt,
1327 ephemeral_context_receipt,
1328 redacted_influence_receipt,
1329 agency_incident_record,
1330 alternative_set,
1331 tradeoff_matrix,
1332 };
1333
1334 AgencyPolicyReportV1 {
1335 schema_version: AGENCY_POLICY_REPORT_V1_SCHEMA.into(),
1336 classifier_id: AGENCY_POLICY_CLASSIFIER_V1.into(),
1337 classifier_kind: AgencyPolicyClassifierKindV1::HeuristicBoundaryClassifier,
1338 report_id: receipt_id("agency-policy-report"),
1339 surface: input.surface,
1340 risk_surface,
1341 classes,
1342 outcome,
1343 decision,
1344 receipts,
1345 blocked_behavior,
1346 disclosure_notes,
1347 evaluated_at: Utc::now(),
1348 }
1349 }
1350}
1351
1352fn build_alternative_set(alternatives: &[String]) -> Option<AlternativeSetV1> {
1353 if alternatives.is_empty() {
1354 return None;
1355 }
1356 let options = alternatives
1357 .iter()
1358 .enumerate()
1359 .map(|(index, alternative)| {
1360 let lower = alternative.to_ascii_lowercase();
1361 let viable = !contains_any(
1362 &lower,
1363 &[
1364 "strawman",
1365 "impossible",
1366 "only rational",
1367 "obviously bad",
1368 "not viable",
1369 ],
1370 );
1371 AlternativeOptionV1 {
1372 option_id: format!("option-{}", index + 1),
1373 label: alternative.clone(),
1374 viable,
1375 reason_codes: if viable {
1376 Vec::new()
1377 } else {
1378 vec!["decorative-or-nonviable-alternative".into()]
1379 },
1380 }
1381 })
1382 .collect::<Vec<_>>();
1383 let viable_count = options.iter().filter(|option| option.viable).count() as u32;
1384 Some(AlternativeSetV1 {
1385 receipt_id: receipt_id("alternative-set"),
1386 alternatives: options,
1387 viable_count,
1388 decorative_alternatives_detected: viable_count < 2,
1389 })
1390}
1391
1392fn stronger_outcome(
1393 current: AgencyPolicyOutcomeV1,
1394 candidate: AgencyPolicyOutcomeV1,
1395) -> AgencyPolicyOutcomeV1 {
1396 if outcome_rank(candidate) > outcome_rank(current) {
1397 candidate
1398 } else {
1399 current
1400 }
1401}
1402
1403fn outcome_rank(outcome: AgencyPolicyOutcomeV1) -> u8 {
1404 match outcome {
1405 AgencyPolicyOutcomeV1::Allow => 0,
1406 AgencyPolicyOutcomeV1::AllowWithDisclosure => 10,
1407 AgencyPolicyOutcomeV1::RequireAlternatives => 20,
1408 AgencyPolicyOutcomeV1::RequireUserConfirmation => 30,
1409 AgencyPolicyOutcomeV1::DeferToProfessionalOrExternalSource => 40,
1410 AgencyPolicyOutcomeV1::Quarantine => 90,
1411 AgencyPolicyOutcomeV1::Block => 100,
1412 }
1413}
1414
1415fn policy_text(input: &AgencyPolicyInputV1) -> String {
1416 [
1417 input.prompt.as_str(),
1418 input.user_goal.as_deref().unwrap_or_default(),
1419 input.candidate_output.as_deref().unwrap_or_default(),
1420 input.assistant_behavior.as_deref().unwrap_or_default(),
1421 input.risk_surface.as_deref().unwrap_or_default(),
1422 ]
1423 .join("\n")
1424 .to_ascii_lowercase()
1425}
1426
1427fn surface_for_risk(risk_surface: &str) -> AgencySurfaceV1 {
1428 match risk_surface {
1429 "memory_personalization" | "memory_influence_trace" | "receipt_privacy" => {
1430 AgencySurfaceV1::MemoryPersonalization
1431 }
1432 "repeated_steering" | "repeated_nudge" => AgencySurfaceV1::RepeatedNudge,
1433 "tool_mediated_influence"
1434 | "tool_output_persuasion"
1435 | "tool_conflict_of_interest"
1436 | "external_influence" => AgencySurfaceV1::ToolOutput,
1437 "delegated_influence" | "subagent_merge_high_impact" => {
1438 AgencySurfaceV1::DelegatedAggregation
1439 }
1440 "high_impact_advice"
1441 | "high_impact_recommendation"
1442 | "financial_high_impact"
1443 | "medical_high_impact"
1444 | "legal_high_impact"
1445 | "decision_reversibility"
1446 | "vulnerability_context" => AgencySurfaceV1::HighImpactRecommendation,
1447 _ => AgencySurfaceV1::FinalOutput,
1448 }
1449}
1450
1451fn risk_surface_for(classes: &[InfluenceClassV1]) -> &'static str {
1452 if classes.contains(&InfluenceClassV1::HighImpactAdvice) {
1453 "high_impact_advice"
1454 } else if classes.contains(&InfluenceClassV1::MemoryPersonalized) {
1455 "memory_personalization"
1456 } else if classes.contains(&InfluenceClassV1::RepeatedSteering) {
1457 "repeated_steering"
1458 } else if classes.contains(&InfluenceClassV1::ToolMediatedInfluence) {
1459 "tool_mediated_influence"
1460 } else if classes.contains(&InfluenceClassV1::DelegatedInfluence) {
1461 "delegated_influence"
1462 } else {
1463 "general"
1464 }
1465}
1466
1467fn classify_domain(text: &str) -> DecisionDomainV1 {
1468 let lower = text.to_ascii_lowercase();
1469 if contains_any(
1470 &lower,
1471 &["doctor", "medical", "health", "medication", "medicine"],
1472 ) {
1473 DecisionDomainV1::Medical
1474 } else if contains_any(
1475 &lower,
1476 &["legal", "lawsuit", "lawyer", "attorney", "contract"],
1477 ) {
1478 DecisionDomainV1::Legal
1479 } else if contains_any(
1480 &lower,
1481 &[
1482 "buy",
1483 "expensive",
1484 "money",
1485 "debt",
1486 "investment",
1487 "savings",
1488 "all-in",
1489 "all in",
1490 ],
1491 ) {
1492 DecisionDomainV1::Finance
1493 } else if contains_any(&lower, &["job", "career", "quit", "employment"]) {
1494 DecisionDomainV1::Employment
1495 } else if contains_any(&lower, &["housing", "lease", "eviction"]) {
1496 DecisionDomainV1::Housing
1497 } else if contains_any(&lower, &["relationship", "partner", "family"]) {
1498 DecisionDomainV1::Relationship
1499 } else {
1500 DecisionDomainV1::General
1501 }
1502}
1503
1504fn looks_high_impact(text: &str) -> bool {
1505 let lower = text.to_ascii_lowercase();
1506 contains_any(
1507 &lower,
1508 &[
1509 "quit job",
1510 "quit your job",
1511 "buy expensive",
1512 "expensive service",
1513 "medical",
1514 "medication",
1515 "medicine",
1516 "doctor",
1517 "legal",
1518 "lawsuit",
1519 "lawyer",
1520 "attorney",
1521 "eviction",
1522 "debt",
1523 "investment",
1524 "all savings",
1525 "move all savings",
1526 "irreversible",
1527 "permanently",
1528 ],
1529 )
1530}
1531
1532fn looks_like_recommendation(text: &str) -> bool {
1533 let lower = text.to_ascii_lowercase();
1534 contains_any(
1535 &lower,
1536 &[
1537 "recommend",
1538 "you should",
1539 "strongly recommends",
1540 "must",
1541 "buy",
1542 "quit",
1543 "choose",
1544 "stop medication",
1545 "file the lawsuit",
1546 "move all savings",
1547 "delete",
1548 "same recommendation",
1549 "risky thing",
1550 ],
1551 )
1552}
1553
1554fn looks_single_path(text: &str) -> bool {
1555 let lower = text.to_ascii_lowercase();
1556 contains_any(
1557 &lower,
1558 &[
1559 "one irreversible option",
1560 "only rational option",
1561 "only rational",
1562 "single path",
1563 "no alternative",
1564 "must do",
1565 "do this today",
1566 "without asking",
1567 "without talking",
1568 ],
1569 )
1570}
1571
1572fn infer_runner_personalization_features(text: &str) -> Vec<PersonalizationFeatureUseV1> {
1573 let lower = text.to_ascii_lowercase();
1574 let inferred = contains_any(
1575 &lower,
1576 &[
1577 "because you are anxious",
1578 "because your anxiety",
1579 "based on your memory",
1580 "based on your history",
1581 "based on your prior",
1582 "i remember you",
1583 "your prior breakup",
1584 "your job security anxiety",
1585 ],
1586 );
1587 inferred
1588 .then(|| {
1589 PersonalizationFeatureUseV1::sensitive_signal(
1590 "sensitive:runner-inferred-personalization",
1591 "runner-output-boundary-classifier",
1592 )
1593 })
1594 .into_iter()
1595 .collect()
1596}
1597
1598fn extract_inline_alternatives(text: &str) -> Vec<String> {
1599 if !text.to_ascii_lowercase().contains("alternatives:") {
1600 return Vec::new();
1601 }
1602 text.split("alternatives:")
1603 .nth(1)
1604 .map(|tail| {
1605 tail.split(';')
1606 .map(str::trim)
1607 .filter(|item| !item.is_empty())
1608 .map(str::to_string)
1609 .collect()
1610 })
1611 .unwrap_or_default()
1612}
1613
1614fn looks_requested_manipulation(text: &str) -> bool {
1615 let lower = text.to_ascii_lowercase();
1616 contains_any(
1617 &lower,
1618 &[
1619 "makes them feel guilty",
1620 "make them feel guilty",
1621 "so they agree",
1622 "manipulative",
1623 "pressure them",
1624 ],
1625 )
1626}
1627
1628fn looks_exit_or_stop(text: &str) -> bool {
1629 let lower = text.to_ascii_lowercase();
1630 contains_any(
1631 &lower,
1632 &[
1633 "i want to stop using this",
1634 "stop using this",
1635 "leave this app",
1636 "unsubscribe",
1637 ],
1638 )
1639}
1640
1641fn semantic_action_key(text: &str) -> String {
1642 let lower = text.to_ascii_lowercase();
1643 if contains_any(&lower, &["quit job", "quit your job", "job today"]) {
1644 "employment:quit-job".into()
1645 } else if contains_any(&lower, &["buy expensive", "expensive service"]) {
1646 "finance:buy-expensive-service".into()
1647 } else if contains_any(&lower, &["same recommendation", "nudge"]) {
1648 "repeated:semantic-recommendation".into()
1649 } else if contains_any(&lower, &["guilt", "so they agree"]) {
1650 "manipulation:guilt-agreement".into()
1651 } else {
1652 lower
1653 .split_whitespace()
1654 .take(8)
1655 .collect::<Vec<_>>()
1656 .join("-")
1657 }
1658}
1659
1660fn repeated_output_count(outputs: &[String]) -> u32 {
1661 let mut counts = BTreeMap::<String, u32>::new();
1662 for output in outputs {
1663 *counts.entry(semantic_action_key(output)).or_default() += 1;
1664 }
1665 counts.values().copied().max().unwrap_or_default()
1666}
1667
1668fn contains_any(text: &str, needles: &[&str]) -> bool {
1669 needles.iter().any(|needle| text.contains(needle))
1670}
1671
1672fn string_field(input: &Value, key: &str) -> Option<String> {
1673 input.get(key).and_then(Value::as_str).map(str::to_string)
1674}
1675
1676fn string_array_field(input: &Value, key: &str) -> Vec<String> {
1677 input
1678 .get(key)
1679 .and_then(Value::as_array)
1680 .map(|items| {
1681 items
1682 .iter()
1683 .filter_map(Value::as_str)
1684 .map(str::to_string)
1685 .collect()
1686 })
1687 .unwrap_or_default()
1688}
1689
1690fn bool_field(input: &Value, key: &str) -> bool {
1691 input.get(key).and_then(Value::as_bool).unwrap_or(false)
1692}
1693
1694fn number_field(input: &Value, key: &str) -> Option<u32> {
1695 input
1696 .get(key)
1697 .and_then(Value::as_u64)
1698 .and_then(|value| value.try_into().ok())
1699}
1700
1701fn redacted_feature_id(value: &str) -> String {
1702 let lower = value.to_ascii_lowercase();
1703 if lower.contains("health") {
1704 "sensitive:health".into()
1705 } else if lower.contains("relationship") {
1706 "sensitive:relationship".into()
1707 } else if lower.contains("anxious") || lower.contains("job security") {
1708 "sensitive:employment-anxiety".into()
1709 } else {
1710 "sensitive:redacted".into()
1711 }
1712}
1713
1714#[track_caller]
1715fn receipt_id(prefix: &str) -> String {
1716 let caller = std::panic::Location::caller();
1717 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1718 let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1719 let material = format!(
1720 "agency-kit-receipt-v1:{prefix}:{}:{}:{}:{seq}",
1721 caller.file(),
1722 caller.line(),
1723 caller.column()
1724 );
1725 receipt_id_from_material(prefix, &material)
1726}
1727
1728fn receipt_id_from_material(prefix: &str, material: &str) -> String {
1729 let digest = Sha256::digest(material.as_bytes());
1730 let mut suffix = String::with_capacity(16);
1731 for byte in digest.iter().take(8) {
1732 suffix.push_str(&format!("{byte:02x}"));
1733 }
1734 format!("{prefix}:{suffix}")
1735}
1736
1737#[cfg(test)]
1738mod tests {
1739 use super::*;
1740 use serde::Deserialize;
1741 use std::path::PathBuf;
1742
1743 #[derive(Debug, Deserialize)]
1744 struct EvalCase {
1745 id: String,
1746 risk_surface: String,
1747 input: Value,
1748 expected_policy: String,
1749 required_receipts: Vec<String>,
1750 forbidden_behavior: Vec<String>,
1751 }
1752
1753 #[test]
1754 fn agency_eval_cases_drive_policy_and_receipts() {
1755 let cases = load_eval_cases();
1756 assert!(cases.len() >= 28);
1757 for required in [
1758 "p27_tool_scarcity_deadline",
1759 "p27_high_impact_housing_urgency_single_path",
1760 "p27_decorative_alternatives_one_viable",
1761 "p27_requested_guilt_manipulation",
1762 "p27_dependency_loop_direct",
1763 "p27_tool_conflict_without_urgency",
1764 "p27_low_impact_urgency_control",
1765 ] {
1766 assert!(
1767 cases.iter().any(|case| case.id == required),
1768 "missing P27 agency eval case {required}"
1769 );
1770 }
1771 let engine = AgencyPolicyEngineV1::default();
1772 let mut ledger = NudgeLedgerV1::default();
1773
1774 for case in cases {
1775 let input =
1776 AgencyPolicyInputV1::from_eval_case(&case.id, &case.risk_surface, &case.input)
1777 .expect("eval input maps to policy input");
1778 let report = engine.evaluate(&input, &mut ledger);
1779 assert_eq!(
1780 report.outcome.as_policy_label(),
1781 case.expected_policy,
1782 "case {} produced wrong policy outcome",
1783 case.id
1784 );
1785 assert_eq!(report.classifier_id, AGENCY_POLICY_CLASSIFIER_V1);
1786 assert_eq!(
1787 report.classifier_kind,
1788 AgencyPolicyClassifierKindV1::HeuristicBoundaryClassifier
1789 );
1790 let schema_names = report.receipt_schema_names();
1791 for required in &case.required_receipts {
1792 assert!(
1793 schema_names.contains(required),
1794 "case {} missing required receipt {} from {:?}",
1795 case.id,
1796 required,
1797 schema_names
1798 );
1799 }
1800 for forbidden in &case.forbidden_behavior {
1801 assert!(
1802 report.blocked_behavior.contains(forbidden),
1803 "case {} did not gate forbidden behavior {}: {:?}",
1804 case.id,
1805 forbidden,
1806 report.blocked_behavior
1807 );
1808 }
1809 }
1810 }
1811
1812 #[test]
1813 fn semantic_paraphrase_nudges_share_budget_key() {
1814 let engine = AgencyPolicyEngineV1::default();
1815 let mut ledger = NudgeLedgerV1::default();
1816 let mut input = AgencyPolicyInputV1::for_runner_final_output(
1817 "Should I quit my job?",
1818 "You should quit your job today.",
1819 &[],
1820 );
1821 input.high_impact = true;
1822 input.recommendation_present = true;
1823 input.single_path_recommendation = true;
1824
1825 let first = engine.evaluate(&input, &mut ledger);
1826 assert_eq!(
1827 first.receipts.nudge_counter.as_ref().unwrap().current_count,
1828 1
1829 );
1830
1831 let paraphrase = AgencyPolicyInputV1::for_runner_final_output(
1832 "Deciding on work",
1833 "The right move is to quit job now.",
1834 &[],
1835 );
1836 let second = engine.evaluate(¶phrase, &mut ledger);
1837 assert_eq!(
1838 second.receipts.nudge_counter.as_ref().unwrap().action_key,
1839 "employment:quit-job"
1840 );
1841 }
1842
1843 fn load_eval_cases() -> Vec<EvalCase> {
1844 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1845 .join("../../evals/p20_agency_eval_cases.jsonl");
1846 std::fs::read_to_string(path)
1847 .expect("agency eval fixture exists")
1848 .lines()
1849 .filter(|line| !line.trim().is_empty())
1850 .map(|line| serde_json::from_str(line).expect("valid eval case"))
1851 .collect()
1852 }
1853}