Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions agent-sidecar/src/hic_records.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ pub fn record_auto_decision(
/// Close an allowing decision with its outcome. A failure is logged (the decision stays open and
/// the log closes it as `outcome_unknown` when it is next opened).
pub fn record_outcome(log: &DecisionLog, seq: u64, outcome: Outcome, detail: &str) {
let _written = try_record_outcome(log, seq, outcome, detail);
}

/// [`record_outcome`], saying whether the outcome was written (C1-V3-R: the approve route reports
/// a missing receipt for a submitted transaction instead of hiding it).
pub fn try_record_outcome(log: &DecisionLog, seq: u64, outcome: Outcome, detail: &str) -> bool {
if let Err(e) = log.record_outcome(
Actor::member("member"),
OutcomeEvent {
Expand All @@ -114,7 +120,9 @@ pub fn record_outcome(log: &DecisionLog, seq: u64, outcome: Outcome, detail: &st
eprintln!(
"citrate-agent-sidecar: the outcome of decision {seq} could not be recorded: {e}"
);
return false;
}
true
}

/// Control characters removed, then cut at a character boundary to at most `max` bytes (the
Expand Down
169 changes: 149 additions & 20 deletions agent-sidecar/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,21 @@ struct ApprovalBody {
to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<String>,
/// C1-V3-R: an identical effect was approved earlier and submitted with an unknown outcome.
/// Absent otherwise.
#[serde(
rename = "priorOutcomeUnknown",
skip_serializing_if = "Option::is_none"
)]
prior_outcome_unknown: Option<PriorOutcomeBody>,
}

/// C1-V3-R: the earlier submission an identical re-proposal must be checked against.
#[derive(Serialize)]
struct PriorOutcomeBody {
#[serde(rename = "txHash")]
tx_hash: Option<String>,
message: String,
}

/// AR-B-024 — refuse a non-loopback control bind unless explicitly opted in.
Expand Down Expand Up @@ -570,15 +585,25 @@ async fn approvals(
.get("data_hex")
.and_then(|v| v.as_str())
.map(str::to_string);
// C1-V3-R: a re-proposal of an effect whose earlier submission has an unknown outcome
// names that transaction, in the summary the member reads and as a field.
let summary = match &p.prior_unknown {
Some(u) => format!("{} · {}", p.description, u.reproposal_note()),
None => p.description,
};
out.push(ApprovalBody {
// AR-B-023: expose the stable call-id (not the tool name) so the
// ceremony can assert "I am approving *this* call" when it posts
// back to /approvals/approve.
id: p.id.clone(),
kind: p.risk_level.clone(),
summary: p.description,
summary,
to,
data,
prior_outcome_unknown: p.prior_unknown.map(|u| PriorOutcomeBody {
tx_hash: u.tx_hash().map(str::to_string),
message: u.message().to_string(),
}),
});
}
Ok(Json(out))
Expand Down Expand Up @@ -620,12 +645,50 @@ async fn reject_head(

/// Extract the call-id the human reviewed from an approve/reject body. `None` for anything that is
/// not a JSON object carrying a non-empty string `id` (PBA-L6b-009).
fn requested_call_id(body: &[u8]) -> Option<String> {
let v: serde_json::Value = serde_json::from_slice(body).ok()?;
fn requested_call_id(v: &serde_json::Value) -> Option<String> {
let id = v.get("id")?.as_str()?;
(!id.is_empty()).then(|| id.to_string())
}

/// C1-V3-R: the optional payment outcome on an approve body (data source: citrate-core's
/// signature ceremony, `hermes_resolve_outcome_unknown`). `Ok(None)` when the body carries no
/// outcome, which is today's behaviour. `Err` (400) for a malformed outcome, a `txHash` or
/// `message` without an outcome, or any outcome on a reject: an unknown outcome is never a
/// rejection, because the member approved it and the transaction went out.
fn requested_outcome(
v: &serde_json::Value,
approve: bool,
) -> Result<Option<citrate_agent_core::hitl::UnknownOutcome>, StatusCode> {
let present = |k: &str| v.get(k).is_some_and(|x| !x.is_null());
if !present("outcome") {
return if present("txHash") || present("message") {
Err(StatusCode::BAD_REQUEST)
} else {
Ok(None)
};
}
if !approve {
return Err(StatusCode::BAD_REQUEST);
}
if v.get("outcome").and_then(|o| o.as_str())
!= Some(citrate_agent_core::hitl::OUTCOME_SUBMITTED_UNKNOWN)
{
return Err(StatusCode::BAD_REQUEST);
}
let optional_str = |k: &str| -> Result<Option<&str>, StatusCode> {
match v.get(k) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(serde_json::Value::String(s)) => Ok(Some(s.as_str())),
Some(_) => Err(StatusCode::BAD_REQUEST),
}
};
let tx_hash = optional_str("txHash")?;
let message = optional_str("message")?;
citrate_agent_core::hitl::UnknownOutcome::new(tx_hash, message)
.map(Some)
.map_err(|_| StatusCode::BAD_REQUEST)
}

/// Shared approve/reject resolution, always bound to a call-id (AR-B-023 / PBA-L6b-009).
fn resolve_body(
st: &Arc<AppState>,
Expand All @@ -636,26 +699,78 @@ fn resolve_body(
if st.estop.is_stopped() {
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
let id = requested_call_id(body).ok_or(StatusCode::BAD_REQUEST)?;
let v: serde_json::Value = serde_json::from_slice(body).map_err(|_| StatusCode::BAD_REQUEST)?;
let id = requested_call_id(&v).ok_or(StatusCode::BAD_REQUEST)?;
// C1-V3-R: validated before anything is recorded or resolved.
let unknown = requested_outcome(&v, approve)?;
// HUP-S2.6: the member's decision on the reviewed head is recorded (write-ahead for an
// approval; fail closed when the record cannot be written). A resolve that does not match
// the head records nothing.
// the head records nothing, except an unknown outcome (below).
//
// C1-V3-R: an unknown outcome is different. Core already broadcast the transaction, so
// refusing here would only hide it: the outcome is delivered and remembered whatever the
// record or the queue says, and a receipt naming the tx is written for its call id even
// when the waiter is gone. Nothing is released by it, so write-ahead does not apply.
let head = st.queue.peek().filter(|p| p.id == id);
let log = st.sessions.records();
let pending_record = match (&log, &head) {
(Some(log), Some(p)) if approve => Some(
record_ceremony_decision(log, p, true).map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?,
),
(Some(log), Some(p)) if approve => {
match record_ceremony_decision(log, &p.id, &ceremony_subject(p), true) {
Ok(seq) => Some(seq),
Err(e) if unknown.is_some() => {
eprintln!("citrate-agent-sidecar: {e}");
None
}
Err(_) => return Err(StatusCode::SERVICE_UNAVAILABLE),
}
}
_ => None,
};
let res = if approve {
st.queue.approve_by_id(&id)
} else {
st.queue.reject_by_id(&id)
let res = match (&unknown, approve) {
(Some(u), _) => st.queue.approve_by_id_outcome_unknown(&id, u.clone()),
(None, true) => st.queue.approve_by_id(&id),
(None, false) => st.queue.reject_by_id(&id),
};
let told = res.is_ok();
// C1-V3-R: the receipt of a submitted tx, whether or not the capsule was told.
let unknown_recorded = match (&log, &unknown) {
(Some(log), Some(u)) => {
let detail = if told {
u.record_detail()
} else {
format!(
"{}; the call was no longer waiting, so the capsule was not told",
u.record_detail()
)
};
let seq = pending_record.or_else(|| {
let subject = head
.as_ref()
.map(ceremony_subject)
.unwrap_or_else(|| format!("approval {id}: no longer pending"));
match record_ceremony_decision(log, &id, &subject, true) {
Ok(seq) => Some(seq),
Err(e) => {
eprintln!("citrate-agent-sidecar: {e}");
None
}
}
});
match seq {
Some(seq) => hic_records::try_record_outcome(
log,
seq,
citrate_agent_records::Outcome::OutcomeUnknown,
&detail,
),
None => false,
}
}
_ => false,
};
match res {
Ok(()) => {
if let (Some(log), Some(p)) = (&log, &head) {
if let (Some(log), Some(p), None) = (&log, &head, &unknown) {
match pending_record {
Some(seq) => hic_records::record_outcome(
log,
Expand All @@ -664,17 +779,23 @@ fn resolve_body(
"released to the capsule; a chain effect was signed and broadcast in core's ceremony first",
),
None => {
if let Err(e) = record_ceremony_decision(log, p, false) {
if let Err(e) = record_ceremony_decision(log, &p.id, &ceremony_subject(p), false) {
eprintln!("citrate-agent-sidecar: {e}");
}
}
}
}
Ok(Json(serde_json::json!({ "ok": true, "resolved": true })))
let mut out = serde_json::json!({ "ok": true, "resolved": true });
if unknown.is_some() {
out["outcome"] =
serde_json::json!(citrate_agent_core::hitl::OUTCOME_SUBMITTED_UNKNOWN);
out["recorded"] = serde_json::json!(unknown_recorded);
}
Ok(Json(out))
}
// The head is not the call the operator reviewed, or its submitter is gone — refuse.
Err(_) => {
if let (Some(log), Some(seq)) = (&log, pending_record) {
if let (Some(log), Some(seq), None) = (&log, pending_record, &unknown) {
hic_records::record_outcome(
log,
seq,
Expand All @@ -687,16 +808,22 @@ fn resolve_body(
}
}

/// HUP-S2.6: one ceremony-bridge decision on the reviewed call `p`.
/// HUP-S2.6: what a ceremony decision on the pending call `p` is about.
fn ceremony_subject(p: &citrate_agent_core::hitl::PendingView) -> String {
format!("{}: {}", p.name, p.description)
}

/// HUP-S2.6: one ceremony-bridge decision on the call `call_id`, bound to it by its evidence.
fn record_ceremony_decision(
log: &citrate_agent_records::DecisionLog,
p: &citrate_agent_core::hitl::PendingView,
call_id: &str,
subject: &str,
allow: bool,
) -> Result<u64, String> {
hic_records::record_member_decision(
log,
"ceremony.capsule_effect",
&format!("{}: {}", p.name, p.description),
subject,
allow,
if allow {
"the member approved this effect in core's ceremony"
Expand All @@ -705,7 +832,7 @@ fn record_ceremony_decision(
},
vec![citrate_agent_records::EvidenceRef {
kind: "approval_call".to_string(),
uri: format!("sidecar:approval/{}", p.id),
uri: format!("sidecar:approval/{call_id}"),
digest: None,
}],
)
Expand Down Expand Up @@ -2388,6 +2515,8 @@ mod mcp_session_tests;
#[cfg(test)]
mod metering_session_tests;
#[cfg(test)]
mod outcome_unknown_tests;
#[cfg(test)]
mod retrieval_http_tests;
#[cfg(test)]
mod session_reattach_tests;
Expand Down
Loading
Loading