Skip to main content

runx_runtime/execution/
skill_front.rs

1// rust-style-allow: large-file - the skill front owns source-type dispatch,
2// domain-act frame construction, and shared sealed-output projection for all
3// first-class skill runners.
4//! The skill front: compiles a skill-run request into an execution (cli-tool,
5//! agent, or graph runner) and seals it through the shared act engine. This is
6//! one of the source-type "fronts" from `plans/governed-execution-layer.md`;
7//! the act engine (`execution::runner`) owns admit -> execute -> seal.
8
9use std::fs;
10use std::path::Path;
11
12use runx_contracts::{ClosureDisposition, JsonNumber, JsonObject, JsonValue};
13use runx_parser::{ActDeclaration, SkillRunnerDefinition, SkillRunnerManifest};
14use serde::Serialize;
15use thiserror::Error;
16
17use crate::RuntimeError;
18use crate::adapter::{InvocationStatus, SkillInvocation, SkillOutput};
19use crate::agent_invocation::{AgentActInvocationSourceType, agent_act_resolution_request};
20use crate::effects::RuntimeEffectRegistry;
21use crate::execution::disposition::agent_answer_disposition_or_closed;
22use crate::execution::orchestrator::SkillRunRequest;
23use crate::execution::output_projection::project_step_output;
24use crate::receipts::signing::strip_receipt_signing_env;
25use crate::receipts::store::ReceiptStoreError;
26use crate::receipts::{
27    DomainActFrame, RuntimeReceiptSignatureConfig, StepSeal, StepSealClosure, seal_step,
28};
29use crate::services::{ReceiptServices, WorkspaceEnv};
30
31mod agent;
32mod graph;
33mod graph_state;
34mod inline_harness;
35pub(crate) mod runner_manifest;
36
37#[cfg(feature = "cli-tool")]
38pub(crate) use self::graph::SkillRunGraphAdapter;
39pub(crate) use self::graph::graph_domain_act_receipt;
40pub(crate) use self::inline_harness::run_inline_harness_with_effects;
41
42use self::agent::execute_agent_skill_run;
43use self::graph::execute_graph_skill_run;
44use self::runner_manifest::{
45    execute_cli_tool_skill_run, load_runner_manifest, resolve_skill_dir, runner_invocation,
46    selected_runner,
47};
48
49pub use super::operator_context::{
50    SkillOperatorContextChain, SkillOperatorContextContextSkill, SkillOperatorContextDocument,
51    SkillOperatorContextNode, SkillOperatorContextOptions, SkillOperatorContextPackage,
52    SkillOperatorContextRegistry, SkillOperatorContextRunner, SkillOperatorContextStep,
53    SkillOperatorContextTarget, SkillOperatorContextTerminal, SkillOperatorContextTool,
54    load_skill_operator_context_chain,
55};
56pub use super::prepared_skill::{
57    PREPARED_SKILL_REPORT_SCHEMA, PreparedCredentialSummary, PreparedEntryProvenance,
58    PreparedGovernanceSummary, PreparedInputSummary, PreparedRequestSummary, PreparedSkillRun,
59    PreparedSkillRunApproval, PreparedSkillRunReport, PreparedSkillRunStatus, PreparedTraceEntry,
60    prepare_skill_run,
61};
62
63// The run-result envelope schema. The string keeps the `skill_run` name, a stable
64// wire contract consumed by the CLI/SDK/cloud, even though the module is now
65// `skill_front`; renaming the wire schema is a separate, versioned change.
66const SKILL_RUN_SCHEMA: &str = "runx.skill_run.v1";
67const GRAPH_SKILL_STATE_SCHEMA: &str = "runx.graph_skill_state.v1";
68
69#[derive(Debug, Error)]
70pub enum SkillRunError {
71    #[error("skill run failed: {0}")]
72    Invalid(String),
73    #[error(transparent)]
74    Runtime(#[from] RuntimeError),
75    #[error(transparent)]
76    ReceiptStore(#[from] ReceiptStoreError),
77}
78
79/// Optional, non-default knobs for a single skill run.
80///
81/// `execute_skill_run` keeps today's behavior (default runner, file-based
82/// answers). The inline harness needs two extra capabilities without touching
83/// the 35+ `SkillRunRequest` construction sites: select a named runner, and
84/// seed answers inline for a single fresh pass (distinct from the `answers_path`
85/// resume channel). Both default to "off", so `execute_skill_run` and every CLI
86/// path are unchanged.
87#[derive(Clone, Debug, Default)]
88pub(crate) struct SkillRunOverrides {
89    /// Select a runner by name instead of the manifest default.
90    pub(crate) runner: Option<String>,
91    /// Answers seeded for a single fresh run, keyed by resolution request id.
92    /// Drives agent/graph runs to completion in one pass; `None` keeps the
93    /// `answers_path` (resume-from-checkpoint) behavior.
94    pub(crate) seeded_answers: Option<JsonObject>,
95}
96
97pub(crate) fn execute_skill_run_with_effects(
98    request: &SkillRunRequest,
99    effects: &RuntimeEffectRegistry,
100) -> Result<JsonValue, SkillRunError> {
101    execute_skill_run_with_overrides(request, &SkillRunOverrides::default(), effects)
102}
103
104pub(crate) fn execute_skill_run_with_overrides(
105    request: &SkillRunRequest,
106    overrides: &SkillRunOverrides,
107    effects: &RuntimeEffectRegistry,
108) -> Result<JsonValue, SkillRunError> {
109    let skill_dir = resolve_skill_dir(&request.skill_path)?;
110    let manifest = load_runner_manifest(&skill_dir)?;
111    let runner = selected_runner(&manifest, overrides.runner.as_deref())?;
112    execute_skill_run_with_resolved(request, overrides, effects, &skill_dir, &manifest, runner)
113}
114
115pub(crate) fn execute_skill_run_with_resolved(
116    request: &SkillRunRequest,
117    overrides: &SkillRunOverrides,
118    effects: &RuntimeEffectRegistry,
119    skill_dir: &Path,
120    manifest: &SkillRunnerManifest,
121    runner: &SkillRunnerDefinition,
122) -> Result<JsonValue, SkillRunError> {
123    execute_skill_run_with_resolved_trust(
124        request, overrides, effects, skill_dir, manifest, runner, false,
125    )
126}
127
128pub(crate) fn execute_prepared_skill_run_with_resolved(
129    request: &SkillRunRequest,
130    overrides: &SkillRunOverrides,
131    effects: &RuntimeEffectRegistry,
132    skill_dir: &Path,
133    manifest: &SkillRunnerManifest,
134    runner: &SkillRunnerDefinition,
135) -> Result<JsonValue, SkillRunError> {
136    execute_skill_run_with_resolved_trust(
137        request, overrides, effects, skill_dir, manifest, runner, true,
138    )
139}
140
141fn execute_skill_run_with_resolved_trust(
142    request: &SkillRunRequest,
143    overrides: &SkillRunOverrides,
144    effects: &RuntimeEffectRegistry,
145    skill_dir: &Path,
146    manifest: &SkillRunnerManifest,
147    runner: &SkillRunnerDefinition,
148    trusted_prepared: bool,
149) -> Result<JsonValue, SkillRunError> {
150    let raw_workspace = WorkspaceEnv::new(request.env.clone(), request.cwd.clone());
151    let receipts = ReceiptServices::from_env_or_local_development(raw_workspace.env())
152        .map_err(|error| SkillRunError::Invalid(error.to_string()))?;
153    let mut runtime_env = request.env.clone();
154    strip_receipt_signing_env(&mut runtime_env);
155    if !trusted_prepared {
156        super::prepared_skill::strip_untrusted_prepared_env(&mut runtime_env);
157    }
158    let workspace = WorkspaceEnv::new(runtime_env, request.cwd.clone());
159    let skill_env = workspace.skill_env_for_skill(skill_dir);
160    if runner.source.source_type == runx_parser::SourceKind::CliTool
161        && request.local_credential.is_some()
162    {
163        return Err(invalid(
164            "local credential process-env delivery is not supported for cli-tool runners",
165        ));
166    }
167    let invocation = runner_invocation(
168        skill_dir,
169        runner,
170        &request.inputs,
171        &skill_env,
172        request.local_credential.as_ref(),
173    )?;
174    if runner.source.source_type == runx_parser::SourceKind::CliTool {
175        return execute_cli_tool_skill_run(
176            request, &workspace, &receipts, manifest, runner, invocation,
177        );
178    }
179    if runner.source.source_type == runx_parser::SourceKind::Graph {
180        return execute_graph_skill_run(
181            request, overrides, effects, &workspace, &receipts, manifest, runner,
182        );
183    }
184
185    execute_agent_skill_run(
186        request, overrides, &workspace, &receipts, manifest, runner, invocation,
187    )
188}
189
190/// Aggregate result of running a skill's declared inline harness (the
191/// `harness.cases` in its runner manifest). Mirrors the publish-harness summary
192/// the registry publish flow records: a status, counts, the per-case assertion
193/// failures, the case names, the receipts each case sealed, and how many cases
194/// exercised a graph (the stable-maturity graph-integration signal).
195#[derive(Clone, Debug, Serialize)]
196pub struct InlineHarnessReport {
197    pub status: &'static str,
198    pub case_count: usize,
199    pub assertion_error_count: usize,
200    pub assertion_errors: Vec<String>,
201    pub case_names: Vec<String>,
202    pub receipt_ids: Vec<String>,
203    pub graph_case_count: usize,
204}
205
206impl InlineHarnessReport {
207    fn not_declared() -> Self {
208        Self {
209            status: "not_declared",
210            case_count: 0,
211            assertion_error_count: 0,
212            assertion_errors: Vec::new(),
213            case_names: Vec::new(),
214            receipt_ids: Vec::new(),
215            graph_case_count: 0,
216        }
217    }
218}
219
220fn agent_invocation_source_type(
221    value: &str,
222) -> Result<AgentActInvocationSourceType, SkillRunError> {
223    AgentActInvocationSourceType::from_contract_value(value)
224        .ok_or_else(|| invalid(format!("unsupported agent source type {value}")))
225}
226
227fn agent_request(
228    invocation: &SkillInvocation,
229    source_type: AgentActInvocationSourceType,
230) -> Result<JsonValue, SkillRunError> {
231    contract_json_value(&agent_act_resolution_request(invocation, source_type)?)
232}
233
234fn needs_agent_output(run_id: &str, request_id: &str, request: JsonValue) -> JsonObject {
235    let mut output = JsonObject::new();
236    output.insert(
237        "schema".to_owned(),
238        JsonValue::String(SKILL_RUN_SCHEMA.to_owned()),
239    );
240    output.insert(
241        "status".to_owned(),
242        JsonValue::String("needs_agent".to_owned()),
243    );
244    output.insert("run_id".to_owned(), JsonValue::String(run_id.to_owned()));
245    output.insert(
246        "requests".to_owned(),
247        JsonValue::Array(vec![request_for_public_loop(request_id, request)]),
248    );
249    output
250}
251
252fn request_for_public_loop(request_id: &str, request: JsonValue) -> JsonValue {
253    let mut object = match request {
254        JsonValue::Object(object) => object,
255        _ => JsonObject::new(),
256    };
257    object.insert("id".to_owned(), JsonValue::String(request_id.to_owned()));
258    object
259        .entry("kind".to_owned())
260        .or_insert_with(|| JsonValue::String("agent_act".to_owned()));
261    JsonValue::Object(object)
262}
263
264fn read_answer(path: &Path, request_id: &str) -> Result<JsonValue, SkillRunError> {
265    let raw = fs::read_to_string(path)
266        .map_err(|source| RuntimeError::io(format!("reading {}", path.display()), source))?;
267    let value = serde_json::from_str::<JsonValue>(&raw).map_err(|source| {
268        RuntimeError::json(format!("parsing answers file {}", path.display()), source)
269    })?;
270    let answers = match &value {
271        JsonValue::Object(object) => match object.get("answers") {
272            Some(JsonValue::Object(nested)) => nested,
273            _ => object,
274        },
275        _ => return Err(invalid("answers file must be a JSON object")),
276    };
277    answers
278        .get(request_id)
279        .cloned()
280        .ok_or_else(|| invalid(format!("answers file did not include {request_id}")))
281}
282
283fn seal_skill_answer(
284    run_id: &str,
285    runner: &SkillRunnerDefinition,
286    stdout: &str,
287    disposition: ClosureDisposition,
288    signature_config: &RuntimeReceiptSignatureConfig,
289    env: &std::collections::BTreeMap<String, String>,
290) -> Result<runx_contracts::Receipt, SkillRunError> {
291    let disposition_label = disposition.label();
292    let succeeded = disposition == ClosureDisposition::Closed;
293    let status = if succeeded {
294        InvocationStatus::Success
295    } else {
296        InvocationStatus::Failure
297    };
298    let skill_output = SkillOutput {
299        status,
300        stdout: stdout.to_owned(),
301        stderr: if succeeded {
302            String::new()
303        } else {
304            format!("agent act closed with {disposition_label}")
305        },
306        exit_code: succeeded.then_some(0),
307        duration_ms: 0,
308        metadata: JsonObject::new(),
309    };
310    seal_skill_output(
311        run_id,
312        runner,
313        &skill_output,
314        disposition,
315        format!("agent_act_{disposition_label}"),
316        format!("agent act closed with {disposition_label}"),
317        signature_config,
318        env,
319    )
320}
321
322/// Build the domain act frame for a governed turn when its runner declares an
323/// `act:` block: the trusted mapping from the driver's pinned beat inputs and the
324/// model's reason text to the receipt's act, decision, and authority. Returns
325/// `None` for runners without an `act:` block (sealed generically, exactly as
326/// before). The model supplies only the reason prose; every structural field is
327/// read from the runner declaration and the trusted inputs, never the model.
328fn domain_act_frame(
329    invocation: &SkillInvocation,
330    answer: &JsonValue,
331    governed_effect: Option<&JsonValue>,
332) -> Option<DomainActFrame> {
333    let act = invocation.source.act.as_ref()?;
334    // Promote the delivered credential into the act's held authority: a governed
335    // turn's receipt records the grants it actually carried, not just the
336    // declared scope.
337    let authority_grant_refs = invocation
338        .credential_delivery
339        .public_observation()
340        .map(|observation| observation.credential_refs.clone())
341        .unwrap_or_default();
342    build_domain_act_frame(
343        act,
344        &invocation.inputs,
345        answer,
346        governed_effect,
347        authority_grant_refs,
348    )
349}
350
351/// The core of [`domain_act_frame`], reusable by the graph path: build the domain
352/// act frame from a declared `act:` block, the trusted run inputs, the model's
353/// authored reason source, and the real governed effect.
354// rust-style-allow: long-function - act-frame construction is intentionally one
355// branch table so each declared field, input fallback, and governed-effect
356// reference is visible in one receipt-shaping pass.
357fn build_domain_act_frame(
358    act: &ActDeclaration,
359    inputs: &runx_contracts::JsonObject,
360    reason_source: &JsonValue,
361    governed_effect: Option<&JsonValue>,
362    authority_grant_refs: Vec<runx_contracts::Reference>,
363) -> Option<DomainActFrame> {
364    use runx_contracts::{
365        ActForm, AuthorityAttenuation, AuthoritySubsetProof, AuthorityTerm, DecisionChoice,
366        Reference, ReferenceType,
367    };
368
369    // A declared field may be a static literal (`form: review`) or driver-pinned
370    // from an input (`form_from: act_form` names the input key). The driver-pinned
371    // input wins, so one generic skill serves every beat.
372    let resolve = |from_key: Option<&str>, literal: Option<&str>| -> Option<String> {
373        from_key
374            .and_then(|key| inputs.get(key))
375            .and_then(JsonValue::as_str)
376            .or(literal)
377            .map(str::trim)
378            .filter(|value| !value.is_empty())
379            .map(str::to_owned)
380    };
381
382    let form = match resolve(act.form_from.as_deref(), act.form.as_deref())
383        .as_deref()
384        .unwrap_or("observation")
385    {
386        "revision" => ActForm::Revision,
387        "reply" => ActForm::Reply,
388        "review" => ActForm::Review,
389        "verification" => ActForm::Verification,
390        _ => ActForm::Observation,
391    };
392    let purpose = resolve(act.purpose_from.as_deref(), act.purpose.as_deref())?;
393    let legitimacy = resolve(act.legitimacy_from.as_deref(), act.legitimacy.as_deref())
394        .unwrap_or_else(|| "Held the declared authority for this act".to_owned());
395
396    // The single model-authored field: the human reason text.
397    let reason = act
398        .reason_from
399        .as_deref()
400        .and_then(|key| reason_source.as_object().and_then(|object| object.get(key)))
401        .and_then(JsonValue::as_str)
402        .map(str::trim)
403        .filter(|value| !value.is_empty())
404        .map_or_else(|| purpose.clone(), str::to_owned);
405
406    // Resolve a trusted input value (a uri) named by the act mapping into a ref.
407    let input_ref = |map_key: Option<&str>, reference_type: ReferenceType| -> Option<Reference> {
408        let uri = inputs.get(map_key?).and_then(JsonValue::as_str)?.trim();
409        (!uri.is_empty()).then(|| Reference::with_uri(reference_type, uri.to_owned()))
410    };
411
412    let decision_choice = act
413        .decision_from
414        .as_deref()
415        .and_then(|key| inputs.get(key))
416        .and_then(JsonValue::as_str)
417        .and_then(map_decision_choice)
418        .unwrap_or(DecisionChoice::Close);
419
420    let reference_type = match act.effect_type.as_deref().unwrap_or("artifact") {
421        "act" => ReferenceType::Act,
422        "tracking_item" => ReferenceType::TrackingItem,
423        "receipt" => ReferenceType::Receipt,
424        "provider" | "provider_event" => ReferenceType::ProviderEvent,
425        "provider_thread" => ReferenceType::ProviderThread,
426        "provider_comment" => ReferenceType::ProviderComment,
427        "github_issue" => ReferenceType::GithubIssue,
428        "external_url" => ReferenceType::ExternalUrl,
429        _ => ReferenceType::Artifact,
430    };
431    let prefix = resolve(
432        act.effect_prefix_from.as_deref(),
433        act.effect_prefix.as_deref(),
434    )
435    .unwrap_or_default();
436    let effect_ref = |id: &str| {
437        let id = id.trim();
438        (!id.is_empty())
439            .then(|| Reference::with_uri(reference_type.clone(), format!("{prefix}{id}")))
440    };
441    let mut artifact_refs = Vec::new();
442    if let Some(reference) = act
443        .effect_from_input
444        .as_deref()
445        .and_then(|key| inputs.get(key))
446        .and_then(JsonValue::as_str)
447        .and_then(effect_ref)
448    {
449        artifact_refs.push(reference);
450    }
451    if let Some(reference) = governed_effect
452        .and_then(|effect| {
453            let field = resolve(act.effect_field_from.as_deref(), act.effect_from.as_deref())?;
454            effect
455                .as_object()
456                .and_then(|object| object.get(field.as_str()))
457                .and_then(JsonValue::as_str)
458                .and_then(effect_ref)
459        })
460        .filter(|reference| !artifact_refs.contains(reference))
461    {
462        artifact_refs.push(reference);
463    }
464
465    // Charter attenuation, read from driver-pinned inputs (the model never sets
466    // authority). The member's child term, the parent charter reference, and the
467    // subset proof each ride a trusted input key named by the act declaration.
468    // Attenuation is recorded only when both a parent and a proof are present; a
469    // term without them is a root and carries no proof, as the receipt verifier
470    // requires.
471    let authority_terms = act
472        .authority_term_from
473        .as_deref()
474        .and_then(|key| inputs.get(key))
475        .and_then(|value| serde_json::to_value(value).ok())
476        .and_then(|value| serde_json::from_value::<AuthorityTerm>(value).ok())
477        .map(|term| vec![term])
478        .unwrap_or_default();
479    let parent_authority_ref = act
480        .authority_parent_from
481        .as_deref()
482        .and_then(|key| inputs.get(key))
483        .and_then(|value| serde_json::to_value(value).ok())
484        .and_then(|value| serde_json::from_value::<Reference>(value).ok());
485    let subset_proof = act
486        .authority_subset_proof_from
487        .as_deref()
488        .and_then(|key| inputs.get(key))
489        .and_then(|value| serde_json::to_value(value).ok())
490        .and_then(|value| serde_json::from_value::<AuthoritySubsetProof>(value).ok());
491    let authority_attenuation = match (parent_authority_ref, subset_proof) {
492        (Some(parent), Some(proof)) => Some(AuthorityAttenuation {
493            parent_authority_ref: Some(parent),
494            subset_proof: Some(proof),
495        }),
496        _ => None,
497    };
498
499    Some(DomainActFrame {
500        form,
501        purpose: purpose.into(),
502        legitimacy: legitimacy.into(),
503        summary: reason.clone().into(),
504        target_refs: input_ref(act.target_from.as_deref(), ReferenceType::TrackingItem)
505            .into_iter()
506            .collect(),
507        artifact_refs,
508        decision_choice,
509        decision_summary: reason.into(),
510        actor_ref: input_ref(act.actor_from.as_deref(), ReferenceType::Principal)
511            .unwrap_or_else(|| Reference::runx(ReferenceType::Principal, "local_runtime")),
512        authority_grant_refs,
513        authority_scope_refs: input_ref(act.authority_from.as_deref(), ReferenceType::Grant)
514            .into_iter()
515            .collect(),
516        authority_terms,
517        authority_attenuation,
518        previous: input_ref(act.previous_from.as_deref(), ReferenceType::Receipt),
519    })
520}
521
522/// Map a driver-pinned decision word onto the receipt's `DecisionChoice`.
523fn map_decision_choice(value: &str) -> Option<runx_contracts::DecisionChoice> {
524    use runx_contracts::DecisionChoice;
525    match value.trim().to_ascii_lowercase().as_str() {
526        "decline" | "reject" | "rejected" | "deny" | "denied" => Some(DecisionChoice::Decline),
527        "close" | "accept" | "accepted" | "approve" | "approved" | "paid" | "settle"
528        | "settled" => Some(DecisionChoice::Close),
529        "continue" | "claim" | "claimed" | "deliver" | "delivered" => {
530            Some(DecisionChoice::Continue)
531        }
532        "defer" | "deferred" => Some(DecisionChoice::Defer),
533        "escalate" | "escalated" => Some(DecisionChoice::Escalate),
534        "monitor" | "monitored" => Some(DecisionChoice::Monitor),
535        _ => None,
536    }
537}
538
539fn seal_skill_output(
540    run_id: &str,
541    runner: &SkillRunnerDefinition,
542    output: &SkillOutput,
543    disposition: ClosureDisposition,
544    reason_code: String,
545    summary: String,
546    signature_config: &RuntimeReceiptSignatureConfig,
547    env: &std::collections::BTreeMap<String, String>,
548) -> Result<runx_contracts::Receipt, SkillRunError> {
549    let graph_name = identifier_segment(run_id);
550    let step_id = identifier_segment(&runner.name);
551    let projection = project_step_output(output);
552    Ok(seal_step(
553        StepSeal {
554            graph_name: &graph_name,
555            step_id: &step_id,
556            attempt: 1,
557            output,
558            projection: &projection,
559            created_at: &crate::time::now_iso8601(),
560            authority_grant_refs: Vec::new(),
561            operator_refs: super::prepared_skill::prepared_receipt_references(env),
562            closure: Some(StepSealClosure {
563                disposition,
564                reason_code,
565                summary,
566            }),
567        },
568        signature_config.signature_policy(),
569    )?)
570}
571
572fn answer_disposition(answer: &JsonValue) -> Result<ClosureDisposition, SkillRunError> {
573    agent_answer_disposition_or_closed(answer).map_err(|error| invalid(format!("{error}")))
574}
575
576fn sealed_output(
577    manifest: &SkillRunnerManifest,
578    run_id: &str,
579    skill_output: &SkillOutput,
580    payload: &JsonValue,
581    receipt: &runx_contracts::Receipt,
582    receipt_value: JsonValue,
583) -> JsonObject {
584    let mut execution = JsonObject::new();
585    execution.insert(
586        "stdout".to_owned(),
587        JsonValue::String(skill_output.stdout.clone()),
588    );
589    execution.insert(
590        "stderr".to_owned(),
591        JsonValue::String(skill_output.stderr.clone()),
592    );
593    execution.insert(
594        "exit_code".to_owned(),
595        skill_output.exit_code.map_or(JsonValue::Null, |exit_code| {
596            JsonValue::Number(JsonNumber::I64(i64::from(exit_code)))
597        }),
598    );
599    execution.insert("structured_output".to_owned(), payload.clone());
600    execution.insert("skill_claim".to_owned(), payload.clone());
601    if let Some(observations) = skill_output
602        .metadata
603        .get(crate::adapter::CREDENTIAL_DELIVERY_OBSERVATIONS_METADATA)
604    {
605        execution.insert(
606            crate::adapter::CREDENTIAL_DELIVERY_OBSERVATIONS_METADATA.to_owned(),
607            observations.clone(),
608        );
609    }
610
611    let mut output = JsonObject::new();
612    output.insert(
613        "schema".to_owned(),
614        JsonValue::String(SKILL_RUN_SCHEMA.to_owned()),
615    );
616    output.insert("status".to_owned(), JsonValue::String("sealed".to_owned()));
617    output.insert(
618        "skill_name".to_owned(),
619        JsonValue::String(manifest.skill.clone().unwrap_or_else(|| "skill".to_owned())),
620    );
621    output.insert("run_id".to_owned(), JsonValue::String(run_id.to_owned()));
622    output.insert(
623        "receipt_id".to_owned(),
624        JsonValue::String(receipt.id.to_string()),
625    );
626    output.insert(
627        "closure".to_owned(),
628        JsonValue::Object(closure_output(&receipt.seal)),
629    );
630    output.insert("receipt".to_owned(), receipt_value);
631    output.insert("execution".to_owned(), JsonValue::Object(execution));
632    output.insert("payload".to_owned(), payload.clone());
633    output
634}
635
636fn closure_output(seal: &runx_contracts::Seal) -> JsonObject {
637    let mut closure = JsonObject::new();
638    closure.insert(
639        "disposition".to_owned(),
640        JsonValue::String(seal.disposition.label().to_owned()),
641    );
642    closure.insert(
643        "reason_code".to_owned(),
644        JsonValue::String(seal.reason_code.to_string()),
645    );
646    closure.insert(
647        "summary".to_owned(),
648        JsonValue::String(seal.summary.to_string()),
649    );
650    closure.insert(
651        "closed_at".to_owned(),
652        JsonValue::String(seal.closed_at.to_string()),
653    );
654    closure
655}
656
657fn normalize_request_id(value: &str) -> String {
658    let mut normalized = String::new();
659    let mut replaced = false;
660    for character in value.chars() {
661        if character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-') {
662            normalized.push(character);
663            replaced = false;
664        } else if !replaced {
665            normalized.push('_');
666            replaced = true;
667        }
668    }
669    normalized
670}
671
672fn identifier_segment(value: &str) -> String {
673    normalize_request_id(value)
674        .trim_matches(['.', '_', '-'])
675        .replace('.', "-")
676}
677
678fn contract_json_value(value: &impl serde::Serialize) -> Result<JsonValue, SkillRunError> {
679    let value = serde_json::to_value(value)
680        .map_err(|source| RuntimeError::json("serializing native skill contract value", source))?;
681    serde_json::from_value(value).map_err(|source| {
682        RuntimeError::json("normalizing native skill contract value", source).into()
683    })
684}
685
686fn invalid(message: impl Into<String>) -> SkillRunError {
687    SkillRunError::Invalid(message.into())
688}