Skip to main content

runx_contracts/act/
assignment.rs

1//! Act assignment envelope: host kind, actor, intent key, and idempotency hashing.
2use std::collections::BTreeMap;
3
4use serde::{Deserialize, Serialize};
5
6use crate::schema::{IsoDateTime, NonEmptyString, RunxSchema};
7use crate::{JsonObject, JsonValue};
8
9mod hash;
10
11use hash::{sha256_prefixed, stable_hash_json};
12
13pub const ACT_ASSIGNMENT_SCHEMA: &str = "runx.act_assignment.v1";
14pub const SHA256_ALGORITHM: &str = "sha256";
15
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
17pub enum ActAssignmentSchema {
18    #[serde(rename = "runx.act_assignment.v1")]
19    V1,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
23#[serde(rename_all = "snake_case")]
24pub enum ActAssignmentHostKind {
25    Cli,
26    Api,
27    GithubIssueComment,
28    System,
29}
30
31// `ActAssignmentActor` / `ActAssignmentHost` fields stay `String`: they feed the
32// fixture-backed idempotency hash pipeline (`normalize_*`, `derive_*`) which is
33// parity-sensitive and must not be reshaped. Builders normalize empty strings
34// away before hashing; the contract keeps the raw inbound envelope permissive.
35#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
36#[serde(deny_unknown_fields)]
37pub struct ActAssignmentActor {
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub actor_id: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub display_name: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub role: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub provider_identity: Option<String>,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
49#[serde(deny_unknown_fields)]
50pub struct ActAssignmentHost {
51    pub kind: ActAssignmentHostKind,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub trigger_ref: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub scope_set: Option<Vec<String>>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub actor: Option<ActAssignmentActor>,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, RunxSchema)]
61#[serde(deny_unknown_fields)]
62pub struct ActAssignmentIdempotency {
63    pub algorithm: String,
64    pub intent_key: NonEmptyString,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub trigger_key: Option<NonEmptyString>,
67    pub content_hash: NonEmptyString,
68}
69
70#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, RunxSchema)]
71#[serde(deny_unknown_fields)]
72#[runx_schema(id = "runx.act_assignment.v1")]
73pub struct ActAssignment {
74    pub schema: ActAssignmentSchema,
75    pub skill_ref: NonEmptyString,
76    pub runner: NonEmptyString,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub source_ref: Option<NonEmptyString>,
79    pub requested_at: IsoDateTime,
80    pub host: ActAssignmentHost,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub input_overrides: Option<JsonObject>,
83    pub idempotency: ActAssignmentIdempotency,
84}
85
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct BuildActAssignment {
89    pub skill_ref: String,
90    pub runner: String,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub source_ref: Option<String>,
93    pub requested_at: String,
94    pub host: ActAssignmentHost,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub input_overrides: Option<JsonObject>,
97}
98
99impl BuildActAssignment {
100    #[must_use]
101    pub fn build(self) -> ActAssignment {
102        let input_overrides = non_empty_object(self.input_overrides);
103        let source_ref = non_empty_string(self.source_ref);
104        let host = normalize_host(self.host);
105        let trigger_key = derive_trigger_key(host.kind.clone(), host.trigger_ref.clone());
106        let content_hash = derive_content_hash(input_overrides.clone());
107
108        ActAssignment {
109            schema: ActAssignmentSchema::V1,
110            skill_ref: self.skill_ref.clone().into(),
111            runner: self.runner.clone().into(),
112            source_ref: source_ref.clone().map(Into::into),
113            requested_at: self.requested_at.into(),
114            host,
115            input_overrides: input_overrides.clone(),
116            idempotency: ActAssignmentIdempotency {
117                algorithm: SHA256_ALGORITHM.to_owned(),
118                intent_key: derive_intent_key(IntentKeyInput {
119                    skill_ref: self.skill_ref,
120                    runner: self.runner,
121                    source_ref,
122                    input_overrides,
123                })
124                .into(),
125                trigger_key: trigger_key.map(Into::into),
126                content_hash: content_hash.into(),
127            },
128        }
129    }
130}
131
132#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields)]
134pub struct IntentKeyInput {
135    pub skill_ref: String,
136    pub runner: String,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub source_ref: Option<String>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub input_overrides: Option<JsonObject>,
141}
142
143/// Derives the stable act assignment intent idempotency key.
144///
145/// Cross-language parity is fixture-backed for the current contract envelope:
146/// ASCII object keys whose order matches the TypeScript oracle, JSON-string
147/// values with `JSON.stringify` escape semantics, and integer numeric values.
148/// Broader key-order parity is owned by `hash-stable-codepoint-cutover`.
149#[must_use]
150pub fn derive_intent_key(input: IntentKeyInput) -> String {
151    sha256_prefixed(&stable_hash_json(&intent_hash_payload(input)))
152}
153
154/// Derives the stable trigger idempotency key when a non-empty trigger exists.
155///
156/// The same fixture-backed hash envelope as [`derive_intent_key`] applies.
157#[must_use]
158pub fn derive_trigger_key(
159    host_kind: ActAssignmentHostKind,
160    trigger_ref: Option<String>,
161) -> Option<String> {
162    let trigger_ref = non_empty_string(trigger_ref)?;
163    let mut payload = BTreeMap::new();
164    payload.insert(
165        "host_kind".to_owned(),
166        JsonValue::String(host_kind_to_string(&host_kind)),
167    );
168    payload.insert("trigger_ref".to_owned(), JsonValue::String(trigger_ref));
169    Some(sha256_prefixed(&stable_hash_json(&JsonValue::Object(
170        payload,
171    ))))
172}
173
174/// Derives the stable content hash for act assignment input overrides.
175///
176/// The same fixture-backed hash envelope as [`derive_intent_key`] applies.
177#[must_use]
178pub fn derive_content_hash(input_overrides: Option<JsonObject>) -> String {
179    sha256_prefixed(&stable_hash_json(&JsonValue::Object(
180        non_empty_object(input_overrides).unwrap_or_default(),
181    )))
182}
183
184fn intent_hash_payload(input: IntentKeyInput) -> JsonValue {
185    let mut payload = BTreeMap::new();
186    payload.insert("skill_ref".to_owned(), JsonValue::String(input.skill_ref));
187    payload.insert("runner".to_owned(), JsonValue::String(input.runner));
188    if let Some(source_ref) = non_empty_string(input.source_ref) {
189        payload.insert("source_ref".to_owned(), JsonValue::String(source_ref));
190    }
191    if let Some(input_overrides) = non_empty_object(input.input_overrides) {
192        payload.insert(
193            "input_overrides".to_owned(),
194            JsonValue::Object(input_overrides),
195        );
196    }
197    JsonValue::Object(payload)
198}
199
200fn non_empty_string(value: Option<String>) -> Option<String> {
201    value.filter(|value| !value.is_empty())
202}
203
204fn non_empty_object(value: Option<JsonObject>) -> Option<JsonObject> {
205    // The TS oracle recursively prunes only `undefined`, which cannot appear in
206    // this JSON value model. Nested nulls and empty objects are preserved as
207    // observable JSON; only a top-level empty override object is omitted.
208    value.filter(|value| !value.is_empty())
209}
210
211fn normalize_host(host: ActAssignmentHost) -> ActAssignmentHost {
212    ActAssignmentHost {
213        kind: host.kind,
214        trigger_ref: non_empty_string(host.trigger_ref),
215        scope_set: normalize_scope_set(host.scope_set),
216        actor: normalize_actor(host.actor),
217    }
218}
219
220fn normalize_scope_set(value: Option<Vec<String>>) -> Option<Vec<String>> {
221    let scope_set: Vec<String> = value
222        .unwrap_or_default()
223        .into_iter()
224        .filter(|scope| !scope.is_empty())
225        .collect();
226    (!scope_set.is_empty()).then_some(scope_set)
227}
228
229fn normalize_actor(actor: Option<ActAssignmentActor>) -> Option<ActAssignmentActor> {
230    let actor = actor?;
231    let normalized = ActAssignmentActor {
232        actor_id: non_empty_string(actor.actor_id),
233        display_name: non_empty_string(actor.display_name),
234        role: non_empty_string(actor.role),
235        provider_identity: non_empty_string(actor.provider_identity),
236    };
237    [
238        &normalized.actor_id,
239        &normalized.display_name,
240        &normalized.role,
241        &normalized.provider_identity,
242    ]
243    .iter()
244    .any(|value| value.is_some())
245    .then_some(normalized)
246}
247
248fn host_kind_to_string(kind: &ActAssignmentHostKind) -> String {
249    match kind {
250        ActAssignmentHostKind::Cli => "cli",
251        ActAssignmentHostKind::Api => "api",
252        ActAssignmentHostKind::GithubIssueComment => "github_issue_comment",
253        ActAssignmentHostKind::System => "system",
254    }
255    .to_owned()
256}