Skip to main content

harn_vm/
autonomy.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::future::Future;
4use std::pin::Pin;
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value as JsonValue;
8use uuid::Uuid;
9
10use crate::event_log::{active_event_log, EventLog, LogEvent, Topic};
11use crate::stdlib::hitl::append_approval_request_on;
12use crate::triggers::dispatcher::current_dispatch_context;
13use crate::trust_graph::{append_trust_record, AutonomyTier, TrustOutcome, TrustRecord};
14use crate::value::{categorized_error, ErrorCategory, VmError, VmValue};
15use harn_builtin_meta::CapabilityId;
16
17/// Stable diagnostic prefix for a deny driven by the `needs-human` autonomy
18/// class. Approval surfaces (Slack, IDE, portal) match on this code to render
19/// the deny distinctly from a normal tier-based block.
20pub const HARN_AUT_NEEDS_HUMAN_CODE: &str = "HARN-AUT-NEEDS-HUMAN";
21
22/// Canonical string value of the `needs-human` autonomy class. Mirrors
23/// `RepairSafety::NeedsHuman.as_str()` from `harn-parser` so the autonomy
24/// surface and the repair-safety surface stay in lockstep.
25pub const NEEDS_HUMAN_AUTONOMY_CLASS: &str = "needs-human";
26
27thread_local! {
28    static AUTONOMY_POLICY_STACK: RefCell<Vec<AutonomyPolicy>> = const { RefCell::new(Vec::new()) };
29}
30
31#[derive(Clone, Debug, Default, Deserialize, Serialize)]
32#[serde(default)]
33pub struct AutonomyPolicy {
34    pub agent_id: Option<String>,
35    pub autonomy_tier: Option<AutonomyTier>,
36    pub tier: Option<AutonomyTier>,
37    pub action_tiers: BTreeMap<String, AutonomyTier>,
38    pub agent_tiers: BTreeMap<String, AutonomyTier>,
39    pub agent_action_tiers: BTreeMap<String, BTreeMap<String, AutonomyTier>>,
40    pub reviewers: Vec<String>,
41    /// Mark the whole policy as `needs-human`: every side-effecting builtin
42    /// covered by this policy raises a structured `HARN-AUT-NEEDS-HUMAN`
43    /// deny, regardless of the resolved `autonomy_tier`.
44    #[serde(default)]
45    pub requires_human: bool,
46    /// Per-action or per-class needs-human tags. Entries match either the
47    /// builtin name (`write_file`) or the action class (`fs.write`).
48    /// Mutually exclusive with auto-apply: an entry here always wins over
49    /// any tier resolution.
50    #[serde(default, alias = "action_requires_human")]
51    pub requires_human_actions: BTreeSet<String>,
52    /// Per-agent needs-human tags. If an agent is listed here, every side
53    /// effect it attempts is treated as needs-human.
54    #[serde(default)]
55    pub requires_human_agents: BTreeSet<String>,
56}
57
58impl AutonomyPolicy {
59    fn effective_tier_for(
60        &self,
61        agent_id: &str,
62        action: &SideEffectAction,
63    ) -> Option<AutonomyTier> {
64        self.agent_action_tiers
65            .get(agent_id)
66            .and_then(|tiers| {
67                tiers
68                    .get(action.builtin)
69                    .or_else(|| tiers.get(action.class))
70                    .copied()
71            })
72            .or_else(|| self.agent_tiers.get(agent_id).copied())
73            .or_else(|| {
74                self.action_tiers
75                    .get(action.builtin)
76                    .or_else(|| self.action_tiers.get(action.class))
77                    .copied()
78            })
79            .or(self.autonomy_tier)
80            .or(self.tier)
81    }
82
83    /// Resolve whether a given (agent, action) is tagged `needs-human` under
84    /// this policy. Any positive signal — blanket `requires_human`, a
85    /// per-agent tag, or a per-builtin/per-class tag — flips the action into
86    /// the needs-human discipline.
87    fn is_needs_human(&self, agent_id: &str, action: &SideEffectAction) -> bool {
88        if self.requires_human {
89            return true;
90        }
91        if self.requires_human_agents.contains(agent_id) {
92            return true;
93        }
94        if self.requires_human_actions.contains(action.builtin)
95            || self.requires_human_actions.contains(action.class)
96        {
97            return true;
98        }
99        false
100    }
101}
102
103fn action(
104    builtin: &'static str,
105    class: &'static str,
106    capability: &'static str,
107) -> SideEffectAction {
108    SideEffectAction {
109        builtin,
110        class,
111        capability,
112    }
113}
114
115fn workspace_write_action(builtin: &'static str, class: &'static str) -> SideEffectAction {
116    action(builtin, class, "workspace.write_text")
117}
118
119fn first_matching_action(
120    name: &str,
121    builtins: &[&'static str],
122    class: &'static str,
123    capability: &'static str,
124) -> Option<SideEffectAction> {
125    builtins
126        .iter()
127        .find(|builtin| **builtin == name)
128        .map(|builtin| action(builtin, class, capability))
129}
130
131fn first_workspace_write_action(
132    name: &str,
133    builtins: &[&'static str],
134    class: &'static str,
135) -> Option<SideEffectAction> {
136    builtins
137        .iter()
138        .find(|builtin| **builtin == name)
139        .map(|builtin| workspace_write_action(builtin, class))
140}
141
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143pub struct SideEffectAction {
144    pub builtin: &'static str,
145    pub class: &'static str,
146    pub capability: &'static str,
147}
148
149#[derive(Clone, Debug)]
150struct AutonomyIdentity {
151    agent_id: String,
152    trace_id: String,
153    tier: AutonomyTier,
154    reviewers: Vec<String>,
155    /// Whether this (agent, action) is tagged `needs-human` by the
156    /// currently-active autonomy policy. When true the dispatcher MUST
157    /// deny auto-apply regardless of `tier`.
158    requires_human: bool,
159}
160
161#[derive(Clone, Debug)]
162pub enum AutonomyDecision {
163    Skip(VmValue),
164    AllowApproved,
165}
166
167pub struct AutonomyPolicyGuard;
168
169impl Drop for AutonomyPolicyGuard {
170    fn drop(&mut self) {
171        AUTONOMY_POLICY_STACK.with(|stack| {
172            stack.borrow_mut().pop();
173        });
174    }
175}
176
177pub fn push_autonomy_policy(policy: AutonomyPolicy) -> AutonomyPolicyGuard {
178    AUTONOMY_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
179    AutonomyPolicyGuard
180}
181
182pub fn current_autonomy_policy() -> Option<AutonomyPolicy> {
183    AUTONOMY_POLICY_STACK.with(|stack| stack.borrow().last().cloned())
184}
185
186/// Per-task ambient-scope swap of the autonomy-policy stack. See
187/// `orchestration::ambient_scope`: a worker inherits its parent's autonomy
188/// tier, and `push_autonomy_policy` guards are held across `.await`, so the
189/// stack must follow the task rather than leak to interleaved siblings.
190pub(crate) fn swap_autonomy_policy_stack(next: Vec<AutonomyPolicy>) -> Vec<AutonomyPolicy> {
191    AUTONOMY_POLICY_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), next))
192}
193
194pub fn is_side_effecting_builtin(name: &str) -> bool {
195    side_effect_action_for_builtin(name).is_some()
196}
197
198pub fn needs_async_side_effect_enforcement(name: &str) -> bool {
199    let Some(action) = side_effect_action_for_builtin(name) else {
200        return false;
201    };
202    current_identity(&action)
203        // `needs-human` always needs the async enforcement path so the
204        // dispatcher can emit the structured deny + approval-request,
205        // even when the resolved tier is `ActAuto`.
206        .is_some_and(|identity| identity.requires_human || identity.tier != AutonomyTier::ActAuto)
207}
208
209pub fn enforce_builtin_side_effect_boxed<'a>(
210    name: &'a str,
211    args: &'a [VmValue],
212) -> Pin<Box<dyn Future<Output = Result<Option<AutonomyDecision>, VmError>> + Send + 'a>> {
213    Box::pin(enforce_builtin_side_effect(name, args))
214}
215
216pub fn side_effect_action_for_builtin(name: &str) -> Option<SideEffectAction> {
217    first_workspace_write_action(
218        name,
219        &[
220            "write_file",
221            "write_file_bytes",
222            "replace_file",
223            "replace_file_result",
224            "replace_file_bytes",
225            "replace_file_bytes_result",
226            "append_file",
227            "append_file_locked",
228        ],
229        "fs.write",
230    )
231    .or_else(|| first_workspace_write_action(name, &["mkdir"], "fs.mkdir"))
232    .or_else(|| first_workspace_write_action(name, &["mkdtemp"], "fs.mkdtemp"))
233    .or_else(|| {
234        first_workspace_write_action(name, &["mkdtemp_in_workspace"], "fs.mkdtemp_in_workspace")
235    })
236    .or_else(|| first_workspace_write_action(name, &["copy_file"], "fs.copy"))
237    .or_else(|| first_matching_action(name, &["delete_file"], "fs.delete", "workspace.delete"))
238    .or_else(|| first_workspace_write_action(name, &["move_file"], "fs.move"))
239    .or_else(|| {
240        first_matching_action(
241            name,
242            &["exec", "exec_at", "shell", "shell_at"],
243            "process.exec",
244            "process.exec",
245        )
246    })
247    .or_else(|| first_matching_action(name, &["host_call"], "host.call", "host.call"))
248    .or_else(|| {
249        first_matching_action(
250            name,
251            &["store_set", "store_delete", "store_save", "store_clear"],
252            "store.write",
253            "store.write",
254        )
255    })
256    .or_else(|| {
257        first_matching_action(
258            name,
259            &[
260                "metadata_set",
261                "metadata_save",
262                "metadata_refresh_hashes",
263                "invalidate_facts",
264                "path_metadata_set",
265                "verification_profiles_set",
266                "verification_profile_record_run",
267            ],
268            "metadata.write",
269            "metadata.write",
270        )
271    })
272    .or_else(|| {
273        first_matching_action(
274            name,
275            &["checkpoint", "checkpoint_delete", "checkpoint_clear"],
276            "checkpoint.write",
277            "checkpoint.write",
278        )
279    })
280    .or_else(|| {
281        first_matching_action(
282            name,
283            &[
284                "sse_server_response",
285                "sse_server_send",
286                "sse_server_heartbeat",
287                "sse_server_flush",
288                "sse_server_close",
289                "sse_server_cancel",
290                "sse_server_mock_receive",
291                "sse_server_mock_disconnect",
292            ],
293            "network.sse.write",
294            "network.sse",
295        )
296    })
297    .or_else(|| {
298        first_matching_action(
299            name,
300            &[
301                "__agent_state_write",
302                "__agent_state_delete",
303                "__agent_state_handoff",
304            ],
305            "agent_state.write",
306            "agent_state.write",
307        )
308    })
309    .or_else(|| first_matching_action(name, &["mcp_release"], "mcp.release", "mcp.release"))
310    .or_else(|| {
311        first_matching_action(
312            name,
313            &[
314                "git.worktree.create",
315                "git.worktree.remove",
316                "git.fetch",
317                "git.rebase",
318                "git.push",
319            ],
320            "git.write",
321            "git.write",
322        )
323    })
324}
325
326pub async fn enforce_builtin_side_effect(
327    name: &str,
328    args: &[VmValue],
329) -> Result<Option<AutonomyDecision>, VmError> {
330    let Some(action) = side_effect_action_for_builtin(name) else {
331        return Ok(None);
332    };
333    enforce_side_effect(action, args, None).await
334}
335
336/// Enforce autonomy at the typed Harness boundary.
337///
338/// Harness methods do not dispatch through their removed ambient-builtin
339/// counterparts, so their capability/method identity must enter the same
340/// autonomy decision engine explicitly. Keep this mapping nominal: the
341/// capability registry supplies the first discriminator and methods are
342/// matched only within that capability.
343pub(crate) async fn enforce_capability_side_effect(
344    harness: &crate::harness::VmHarness,
345    capability: CapabilityId,
346    method: &str,
347    args: &[VmValue],
348) -> Result<Option<AutonomyDecision>, VmError> {
349    let action = match (capability, method) {
350        (CapabilityId::Fs, "write_text") => Some(workspace_write_action("write_text", "fs.write")),
351        (CapabilityId::Fs, "write_bytes") => {
352            Some(workspace_write_action("write_bytes", "fs.write"))
353        }
354        (CapabilityId::Fs, "append_text") => {
355            Some(workspace_write_action("append_text", "fs.write"))
356        }
357        (CapabilityId::Fs, "mkdir") => Some(workspace_write_action("mkdir", "fs.mkdir")),
358        (CapabilityId::Fs, "mkdtemp") => Some(workspace_write_action("mkdtemp", "fs.mkdtemp")),
359        (CapabilityId::Fs, "copy") => Some(workspace_write_action("copy", "fs.copy")),
360        (CapabilityId::Fs, "move") => Some(workspace_write_action("move", "fs.move")),
361        (CapabilityId::Fs, "delete") => Some(action("delete", "fs.delete", "workspace.delete")),
362        (CapabilityId::Process, "exec")
363        | (CapabilityId::Process, "exec_at")
364        | (CapabilityId::Process, "shell")
365        | (CapabilityId::Process, "shell_at")
366        | (CapabilityId::Process, "run") => Some(action("process", "process.exec", "process.exec")),
367        _ => None,
368    };
369    match action {
370        Some(action) => enforce_side_effect(action, args, Some(harness)).await,
371        None => Ok(None),
372    }
373}
374
375async fn enforce_side_effect(
376    action: SideEffectAction,
377    args: &[VmValue],
378    harness: Option<&crate::harness::VmHarness>,
379) -> Result<Option<AutonomyDecision>, VmError> {
380    let Some(identity) = current_identity(&action) else {
381        return Ok(None);
382    };
383    // `needs-human` is a transverse discipline: it forbids auto-apply even
384    // when the resolved tier is `ActAuto`. Check this *before* tier
385    // dispatch so no tier can ever override it.
386    if identity.requires_human {
387        emit_proposal_event(identity.tier, action, args).await?;
388        let request_id = append_needs_human_approval_request(&identity, action, args).await?;
389        append_enforcement_record(
390            &identity,
391            action,
392            args,
393            TrustOutcome::Denied,
394            Some(request_id.clone()),
395        )
396        .await?;
397        return Err(needs_human_deny_error(&identity, action, &request_id));
398    }
399    match identity.tier {
400        AutonomyTier::ActAuto => Ok(None),
401        AutonomyTier::Shadow => {
402            emit_proposal_event(identity.tier, action, args).await?;
403            append_enforcement_record(&identity, action, args, TrustOutcome::Denied, None).await?;
404            Ok(Some(AutonomyDecision::Skip(VmValue::Nil)))
405        }
406        AutonomyTier::Suggest => {
407            emit_proposal_event(identity.tier, action, args).await?;
408            let request_id = append_nonblocking_approval_request(&identity, action, args).await?;
409            append_enforcement_record(
410                &identity,
411                action,
412                args,
413                TrustOutcome::Denied,
414                Some(request_id),
415            )
416            .await?;
417            Ok(Some(AutonomyDecision::Skip(VmValue::Nil)))
418        }
419        AutonomyTier::ActWithApproval => {
420            let approval = request_approval_before_effect(&identity, action, args, harness).await?;
421            append_enforcement_record(
422                &identity,
423                action,
424                args,
425                TrustOutcome::Success,
426                approval.request_id,
427            )
428            .await?;
429            Ok(Some(AutonomyDecision::AllowApproved))
430        }
431    }
432}
433
434fn current_identity(action: &SideEffectAction) -> Option<AutonomyIdentity> {
435    let scoped = current_autonomy_policy();
436    let dispatch = current_dispatch_context();
437    let agent_id = scoped
438        .as_ref()
439        .and_then(|policy| policy.agent_id.clone())
440        .or_else(|| dispatch.as_ref().map(|context| context.agent_id.clone()))
441        .unwrap_or_else(|| "runtime".to_string());
442    let tier = scoped
443        .as_ref()
444        .and_then(|policy| policy.effective_tier_for(&agent_id, action))
445        .or_else(|| dispatch.as_ref().map(|context| context.autonomy_tier))?;
446    let trace_id = dispatch
447        .as_ref()
448        .map(|context| context.trigger_event.trace_id.0.clone())
449        .unwrap_or_else(|| format!("trace-{}", Uuid::now_v7()));
450    let reviewers = scoped
451        .as_ref()
452        .map(|policy| policy.reviewers.clone())
453        .filter(|reviewers| !reviewers.is_empty())
454        .unwrap_or_default();
455    let requires_human = scoped
456        .as_ref()
457        .map(|policy| policy.is_needs_human(&agent_id, action))
458        .unwrap_or(false);
459    Some(AutonomyIdentity {
460        agent_id,
461        trace_id,
462        tier,
463        reviewers,
464        requires_human,
465    })
466}
467
468fn detail_for(action: SideEffectAction, args: &[VmValue]) -> JsonValue {
469    serde_json::json!({
470        "builtin": action.builtin,
471        "action_class": action.class,
472        "args": args.iter().map(crate::llm::vm_value_to_json).collect::<Vec<_>>(),
473    })
474}
475
476fn needs_human_detail(action: SideEffectAction, args: &[VmValue]) -> JsonValue {
477    let mut detail = detail_for(action, args);
478    if let Some(obj) = detail.as_object_mut() {
479        obj.insert(
480            "autonomy_class".to_string(),
481            JsonValue::String(NEEDS_HUMAN_AUTONOMY_CLASS.to_string()),
482        );
483        obj.insert("requires_human".to_string(), JsonValue::Bool(true));
484        obj.insert(
485            "deny_code".to_string(),
486            JsonValue::String(HARN_AUT_NEEDS_HUMAN_CODE.to_string()),
487        );
488    }
489    detail
490}
491
492async fn emit_proposal_event(
493    tier: AutonomyTier,
494    action: SideEffectAction,
495    args: &[VmValue],
496) -> Result<(), VmError> {
497    let Some(context) = current_dispatch_context() else {
498        return Ok(());
499    };
500    let Some(log) = active_event_log() else {
501        return Ok(());
502    };
503    let topic = Topic::new(crate::TRIGGER_OUTBOX_TOPIC)
504        .map_err(|error| VmError::Runtime(format!("autonomy proposal topic error: {error}")))?;
505    let mut headers = BTreeMap::new();
506    headers.insert(
507        "trace_id".to_string(),
508        context.trigger_event.trace_id.0.clone(),
509    );
510    headers.insert("agent".to_string(), context.agent_id.clone());
511    headers.insert("autonomy_tier".to_string(), tier.as_str().to_string());
512    let payload = serde_json::json!({
513        "agent": context.agent_id,
514        "action": context.action,
515        "builtin": action.builtin,
516        "action_class": action.class,
517        "args": args.iter().map(crate::llm::vm_value_to_json).collect::<Vec<_>>(),
518        "trace_id": context.trigger_event.trace_id.0,
519        "replay_of_event_id": context.replay_of_event_id,
520        "autonomy_tier": tier,
521        "proposal": true,
522    });
523    log.append(
524        &topic,
525        LogEvent::new("dispatch_proposed", payload).with_headers(headers),
526    )
527    .await
528    .map(|_| ())
529    .map_err(|error| VmError::Runtime(format!("failed to append autonomy proposal: {error}")))
530}
531
532async fn append_nonblocking_approval_request(
533    identity: &AutonomyIdentity,
534    action: SideEffectAction,
535    args: &[VmValue],
536) -> Result<String, VmError> {
537    let log = active_event_log().ok_or_else(|| {
538        categorized_error(
539            "autonomy approval requires an active event log",
540            ErrorCategory::ToolRejected,
541        )
542    })?;
543    append_approval_request_on(
544        &log,
545        identity.agent_id.clone(),
546        identity.trace_id.clone(),
547        action.class.to_string(),
548        detail_for(action, args),
549        identity.reviewers.clone(),
550    )
551    .await
552}
553
554/// Emit a non-blocking approval request tagged with the `needs-human`
555/// autonomy class. Surfaces (Slack-approval, IDE, portal) match on the
556/// `autonomy_class` field in the request payload's `detail` to render the
557/// pending row distinctly from a normal tier-driven approval ask.
558async fn append_needs_human_approval_request(
559    identity: &AutonomyIdentity,
560    action: SideEffectAction,
561    args: &[VmValue],
562) -> Result<String, VmError> {
563    let log = active_event_log().ok_or_else(|| {
564        categorized_error(
565            "needs-human autonomy class requires an active event log",
566            ErrorCategory::ToolRejected,
567        )
568    })?;
569    append_approval_request_on(
570        &log,
571        identity.agent_id.clone(),
572        identity.trace_id.clone(),
573        format!("{}#needs-human", action.class),
574        needs_human_detail(action, args),
575        identity.reviewers.clone(),
576    )
577    .await
578}
579
580/// Build the structured deny returned when a `needs-human`-tagged side
581/// effect is attempted. The message is prefixed with [`HARN_AUT_NEEDS_HUMAN_CODE`]
582/// so approval surfaces and structured-error consumers can match on a stable
583/// token rather than substring-matching the human-readable text.
584fn needs_human_deny_error(
585    identity: &AutonomyIdentity,
586    action: SideEffectAction,
587    request_id: &str,
588) -> VmError {
589    categorized_error(
590        format!(
591            "{code}: side effect `{builtin}` ({class}) is tagged `needs-human` for agent `{agent}`; \
592             auto-apply is forbidden regardless of autonomy tier `{tier}`. \
593             Approval request `{request_id}` was queued.",
594            code = HARN_AUT_NEEDS_HUMAN_CODE,
595            builtin = action.builtin,
596            class = action.class,
597            agent = identity.agent_id,
598            tier = identity.tier.as_str(),
599            request_id = request_id,
600        ),
601        ErrorCategory::ToolRejected,
602    )
603}
604
605struct ApprovalOutcome {
606    request_id: Option<String>,
607}
608
609async fn request_approval_before_effect(
610    identity: &AutonomyIdentity,
611    action: SideEffectAction,
612    args: &[VmValue],
613    harness: Option<&crate::harness::VmHarness>,
614) -> Result<ApprovalOutcome, VmError> {
615    active_event_log().ok_or_else(|| {
616        categorized_error(
617            "act_with_approval requires an active event log",
618            ErrorCategory::ToolRejected,
619        )
620    })?;
621    let detail = detail_for(action, args);
622    let approval = crate::stdlib::hitl::request_approval_for_side_effect(
623        harness,
624        action.class,
625        detail,
626        identity.agent_id.clone(),
627        identity.reviewers.clone(),
628        vec![action.capability.to_string()],
629    )
630    .await?;
631    let request_id = approval
632        .as_dict()
633        .and_then(|dict| dict.get("request_id"))
634        .map(VmValue::display);
635    Ok(ApprovalOutcome { request_id })
636}
637
638async fn append_enforcement_record(
639    identity: &AutonomyIdentity,
640    action: SideEffectAction,
641    args: &[VmValue],
642    outcome: TrustOutcome,
643    request_id: Option<String>,
644) -> Result<(), VmError> {
645    let Some(log) = active_event_log() else {
646        return Ok(());
647    };
648    let mut record = TrustRecord::new(
649        identity.agent_id.clone(),
650        action.class.to_string(),
651        None,
652        outcome,
653        identity.trace_id.clone(),
654        identity.tier,
655    );
656    let enforcement = if identity.requires_human {
657        // `needs-human` always denies regardless of tier — record the
658        // distinct enforcement label so audit consumers can filter on it
659        // without re-deriving the discipline from policy snapshots.
660        "needs_human_denied"
661    } else {
662        match identity.tier {
663            AutonomyTier::Shadow => "shadow_noop",
664            AutonomyTier::Suggest => "suggest_approval_request",
665            AutonomyTier::ActWithApproval => "approval_granted",
666            AutonomyTier::ActAuto => "auto",
667        }
668    };
669    record.metadata.insert(
670        "autonomy.enforcement".to_string(),
671        serde_json::json!(enforcement),
672    );
673    record
674        .metadata
675        .insert("builtin".to_string(), serde_json::json!(action.builtin));
676    record
677        .metadata
678        .insert("action_class".to_string(), serde_json::json!(action.class));
679    // Every record carries an explicit autonomy class so the trust-graph
680    // record (`TrustRecord.metadata.autonomy_class`) flows downstream into
681    // approval surfaces and receipt envelopes. `needs-human` is mutually
682    // exclusive with the tier-based labels.
683    let autonomy_class = if identity.requires_human {
684        NEEDS_HUMAN_AUTONOMY_CLASS.to_string()
685    } else {
686        identity.tier.as_str().to_string()
687    };
688    record.metadata.insert(
689        "autonomy_class".to_string(),
690        serde_json::json!(autonomy_class),
691    );
692    record.metadata.insert(
693        "requires_human".to_string(),
694        serde_json::json!(identity.requires_human),
695    );
696    if identity.requires_human {
697        record.metadata.insert(
698            "deny_code".to_string(),
699            serde_json::json!(HARN_AUT_NEEDS_HUMAN_CODE),
700        );
701    }
702    record.metadata.insert(
703        "args".to_string(),
704        serde_json::json!(args
705            .iter()
706            .map(crate::llm::vm_value_to_json)
707            .collect::<Vec<_>>()),
708    );
709    if let Some(request_id) = request_id {
710        record.metadata.insert(
711            "approval_request_id".to_string(),
712            serde_json::json!(request_id),
713        );
714    }
715    append_trust_record(&log, &record)
716        .await
717        .map(|_| ())
718        .map_err(|error| VmError::Runtime(format!("autonomy trust graph append: {error}")))
719}