Skip to main content

aidens_agency_kit/
lib.rs

1//! AiDENs boundary policy for agency and influence governance.
2//!
3//! This crate owns only AiDENs-facing influence classification and gating.
4//! It does not define memory, evidence, verification, repair, provider, or
5//! tool-runtime truth.
6
7use 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(&paraphrase, &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}