Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@

### Added

- Settled external-action candidates can now be reconciled idempotently after
acknowledgement loss without a WAL store, transition context, or claim
grant. An exact retained candidate returns the original admitted settlement
and commit digest without appending history or making adapter execution
reachable. A different valid candidate conflicts, malformed candidates fail
ordinary settlement validation, and duplicated settlement records remain a
recovery obstruction.
- Echo now independently admits compiler-produced Edict Core and Target IR for
one non-callable external request, verifies its exact source, target profile,
result, basis, and capability closure, independently corroborates the complete
Expand Down
164 changes: 137 additions & 27 deletions crates/warp-core/src/external_action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1347,6 +1347,42 @@ pub fn admit_external_action_settlement(
})
}

/// Reconciles one retained adapter settlement after acknowledgement loss.
///
/// This path exposes no WAL store, transition context, or claim grant. It can
/// therefore return only the exact settlement that is already durable. A
/// different valid candidate is a conflict; a malformed candidate fails the
/// ordinary request-and-claim validation before comparison.
pub fn reconcile_external_action_settlement_retry(
coordinator: &ExternalActionCoordinatorV1,
candidate: ExternalActionSettlementCandidateV1,
) -> Result<AdmittedExternalActionSettlementV1, ExternalActionProtocolErrorV1> {
coordinator.ensure_ready()?;
let recovered = coordinator
.index
.get(candidate.request_id)
.ok_or(ExternalActionProtocolErrorV1::MissingRequest)?;
let claim = recovered
.claim
.ok_or(ExternalActionProtocolErrorV1::MissingClaim)?;
validate_settlement_candidate(&recovered.request, &claim, &candidate)?;
let candidate = ExternalActionSettlementV1::from_candidate(candidate);
let settlement = recovered
.settlement
.as_ref()
.ok_or(ExternalActionProtocolErrorV1::MissingSettlement)?;
let settlement_commit_digest = recovered
.settlement_commit_digest
.ok_or(ExternalActionProtocolErrorV1::MissingSettlement)?;
if settlement != &candidate {
return Err(ExternalActionProtocolErrorV1::ConflictingSettlement);
}
Ok(AdmittedExternalActionSettlementV1 {
settlement: settlement.clone(),
settlement_commit_digest,
})
}

/// Observes request, claim, and settlement posture in an arbitrary recovery report.
///
/// This projection carries no transition or replay authority. Use
Expand Down Expand Up @@ -1438,7 +1474,10 @@ fn apply_recovered_settlement(
.ok_or(ExternalActionProtocolErrorV1::MissingClaim)?;
validate_settlement(&entry.request, &claim, &settlement)?;
if let Some(existing) = &entry.settlement {
return if existing == &settlement {
let existing_commit = entry
.settlement_commit_digest
.ok_or(ExternalActionProtocolErrorV1::MissingSettlement)?;
return if existing == &settlement && existing_commit == commit_digest {
Err(ExternalActionProtocolErrorV1::DuplicateSettlement)
} else {
Err(ExternalActionProtocolErrorV1::ConflictingSettlement)
Expand Down Expand Up @@ -1782,8 +1821,11 @@ mod tests {
}
}

#[test]
fn conflicting_recovered_settlement_is_obstructed() {
fn claimed_index() -> (
ExternalActionRequestV1,
ExternalActionClaimV1,
RecoveredExternalActionIndexV1,
) {
let request = request();
let claim = ExternalActionClaimV1::for_request(
&request,
Expand All @@ -1802,37 +1844,105 @@ mod tests {
settlement_commit_digest: None,
posture: RecoveredExternalActionPostureV1::Claimed,
}));
let first =
ExternalActionSettlementV1::from_candidate(ExternalActionSettlementCandidateV1::new(
request.request_id,
claim.attempt_id,
claim.adapter_id,
ExternalActionSettlementKindV1::Succeeded,
request.settlement_schema_digest,
request.basis_digest,
b"first".to_vec(),
digest("test.schema-evidence"),
digest("test.external-evidence"),
));
(request, claim, index)
}

fn settlement(
request: &ExternalActionRequestV1,
claim: &ExternalActionClaimV1,
bytes: &[u8],
) -> ExternalActionSettlementV1 {
ExternalActionSettlementV1::from_candidate(ExternalActionSettlementCandidateV1::new(
request.request_id,
claim.attempt_id,
claim.adapter_id,
ExternalActionSettlementKindV1::Succeeded,
request.settlement_schema_digest,
request.basis_digest,
bytes.to_vec(),
digest("test.schema-evidence"),
digest("test.external-evidence"),
))
}

#[test]
fn identical_recovered_settlement_is_a_duplicate() {
let (request, claim, mut index) = claimed_index();
let settlement = settlement(&request, &claim, b"same");
let commit = digest("settlement.commit");

assert_eq!(
apply_recovered_settlement(&mut index, settlement.clone(), commit),
Ok(())
);
let root = index.root_digest();
assert_eq!(
apply_recovered_settlement(&mut index, settlement, commit),
Err(ExternalActionProtocolErrorV1::DuplicateSettlement)
);
assert_eq!(index.len(), 1);
assert_eq!(index.root_digest(), root);
}

#[test]
fn conflicting_recovered_settlement_is_obstructed() {
let (request, claim, mut index) = claimed_index();
let first = settlement(&request, &claim, b"first");
assert_eq!(
apply_recovered_settlement(&mut index, first, digest("settlement.commit")),
Ok(())
);
let conflicting =
ExternalActionSettlementV1::from_candidate(ExternalActionSettlementCandidateV1::new(
request.request_id,
claim.attempt_id,
claim.adapter_id,
ExternalActionSettlementKindV1::Succeeded,
request.settlement_schema_digest,
request.basis_digest,
b"second".to_vec(),
digest("test.schema-evidence"),
digest("test.external-evidence"),
));
let conflicting = settlement(&request, &claim, b"second");
assert_eq!(
apply_recovered_settlement(&mut index, conflicting, digest("conflict.commit")),
Err(ExternalActionProtocolErrorV1::ConflictingSettlement)
);
}

#[test]
fn identical_settlement_under_another_commit_is_conflicting() {
let (request, claim, mut index) = claimed_index();
let settlement = settlement(&request, &claim, b"same");
assert_eq!(
apply_recovered_settlement(&mut index, settlement.clone(), digest("settlement.commit")),
Ok(())
);
assert_eq!(
apply_recovered_settlement(
&mut index,
settlement,
digest("different-settlement.commit")
),
Err(ExternalActionProtocolErrorV1::ConflictingSettlement)
);
}

#[test]
fn fixed_seed_settlement_mutations_are_conflicting() {
const SEED: u64 = 0x5e77_1e5e_77e5_0001;
let (request, claim, mut index) = claimed_index();
let first = settlement(&request, &claim, &SEED.to_le_bytes());
assert_eq!(
apply_recovered_settlement(&mut index, first, digest("property-settlement.commit")),
Ok(())
);

let mut state = SEED;
for ordinal in 0_u8..32 {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
let mut bytes = state.to_le_bytes().to_vec();
bytes.push(ordinal);
let conflicting = settlement(&request, &claim, &bytes);
assert_eq!(
apply_recovered_settlement(
&mut index,
conflicting,
digest("property-conflict.commit")
),
Err(ExternalActionProtocolErrorV1::ConflictingSettlement)
);
}
}
}
Loading
Loading