Skip to content

Commit ff17e0a

Browse files
committed
Merge: flow.install_revoked: a verified revocation stops a version's runs at their next host call
2 parents 62c5089 + d87905e commit ff17e0a

18 files changed

Lines changed: 938 additions & 26 deletions

File tree

‎crates/basal-core/src/driver.rs‎

Lines changed: 152 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,51 @@ impl Activation<'_> {
356356
))
357357
}
358358

359+
fn end_revoked(&self, detail: String) -> Result<Flow> {
360+
let unknown = self.rt.store().write(|tx| {
361+
let unknown = journal::unknown_positions(tx, &self.lease.run_id)?;
362+
let exit = if unknown.is_empty() {
363+
Exit::Failed {
364+
kind: "version_revoked".into(),
365+
detail: detail.clone(),
366+
}
367+
} else {
368+
Exit::NeedsReconcile {
369+
positions: unknown.clone(),
370+
}
371+
};
372+
runs::exit(tx, &self.lease, &exit)?;
373+
Ok(unknown)
374+
})?;
375+
self.at(Boundary::Exited)?;
376+
if !unknown.is_empty() {
377+
return Ok(done(
378+
ActivationEnd::NeedsReconcile { positions: unknown },
379+
false,
380+
));
381+
}
382+
Ok(done(
383+
ActivationEnd::Failed {
384+
kind: "version_revoked".into(),
385+
detail,
386+
},
387+
false,
388+
))
389+
}
390+
391+
fn finish_revoked_call(&mut self, call: &HostCall, reason: String) -> Result<Flow> {
392+
let (outcome, _) = self
393+
.rt
394+
.store()
395+
.write(|tx| journal::release_next(tx, &self.lease, &[call.position]))?;
396+
if let Some(outcome) = outcome {
397+
// Commit the typed refusal before ending the run. A script catching
398+
// this reply cannot dispatch again because its worker is retired.
399+
let _ = self.send(&ParentMessage::Deliver(outcome));
400+
}
401+
self.end_revoked(reason)
402+
}
403+
359404
fn send(&mut self, message: &ParentMessage) -> std::result::Result<(), ChannelError> {
360405
self.worker.send(message)
361406
}
@@ -373,13 +418,10 @@ impl Activation<'_> {
373418
fn run(&mut self) -> Result<Flow> {
374419
// The install gate (`crate::gate`): the lease is held and nothing
375420
// has reached the worker or the host yet, not even a resend of a
376-
// call an earlier activation left open. A revoke that lands after
377-
// this check is deliberately not looked for again during this
378-
// activation. Core itself refuses a revoked version's sink writes
379-
// (digest items and status lines, the only way a flow reaches an
380-
// agent), and the next activation asks again, so what a revoked
381-
// version can still do is bounded by this one activation. The
382-
// worker was handed nothing, so it stays reusable.
421+
// call an earlier activation left open. Verified pushes can record
422+
// revocation afterward; each dispatch attempt checks that durable
423+
// record without another network read. The worker was handed nothing
424+
// when this gate closes, so it stays reusable.
383425
if let Gate::Closed(end) = self.rt.check_install(&self.lease, &self.run)? {
384426
return Ok(done(end, true));
385427
}
@@ -610,7 +652,10 @@ impl Activation<'_> {
610652
// If only the row remains, recovery completes that intent.
611653
let flow_id = self.run.flow_id.clone();
612654
let limits = self.rt.config.kv;
613-
self.rt.store().write(|tx| {
655+
let revoked = self.rt.store().write(|tx| {
656+
if let Some(reason) = crate::revocation::dispatch_check(tx, &run_id)? {
657+
return Ok(Some(reason));
658+
}
614659
let outcome =
615660
if matches!(row.kind, CallKind::Primitive(Primitive::InstanceEnd)) {
616661
crate::templates::request_end(tx, &run_id, &row.args)?
@@ -624,8 +669,12 @@ impl Activation<'_> {
624669
None,
625670
&outcome,
626671
Source::Local,
627-
)
672+
)?;
673+
Ok(None)
628674
})?;
675+
if let Some(reason) = revoked {
676+
return self.end_revoked(reason);
677+
}
629678
self.at(Boundary::LocalCommitted {
630679
position: row.position,
631680
})?;
@@ -721,6 +770,36 @@ impl Activation<'_> {
721770
}
722771

723772
fn on_host_call(&mut self, call: HostCall) -> Result<Flow> {
773+
if let Some(reason) = self
774+
.rt
775+
.store()
776+
.read(|c| crate::revocation::dispatch_check(c, &self.lease.run_id))?
777+
{
778+
if call.position == self.next_position {
779+
self.refuse(
780+
&call,
781+
&self.run.flow_id.clone(),
782+
&Refusal::new("version_revoked", &reason),
783+
)?;
784+
} else if call.position < self.next_position {
785+
self.rt.store().write(|tx| {
786+
if journal::row(tx, &self.lease.run_id, call.position)?
787+
.is_some_and(|r| r.class == StoredClass::Sync && r.outcome.is_none())
788+
{
789+
journal::accept_outcome(
790+
tx,
791+
&self.lease.run_id,
792+
call.position,
793+
None,
794+
&kv::rejection("version_revoked", &reason),
795+
Source::Local,
796+
)?;
797+
}
798+
Ok(())
799+
})?;
800+
}
801+
return self.finish_revoked_call(&call, reason);
802+
}
724803
if matches!(call.kind, CallKind::Tool { .. }) {
725804
return self.fail(
726805
"profile_violation",
@@ -810,6 +889,13 @@ impl Activation<'_> {
810889
position: call.position,
811890
})?;
812891
self.rt.shared.signal.bump();
892+
if let Some(reason) = self
893+
.rt
894+
.store()
895+
.read(|c| crate::revocation::dispatch_check(c, &run_id))?
896+
{
897+
return self.finish_revoked_call(&call, reason);
898+
}
813899
return Ok(Flow::Continue);
814900
}
815901
Err(e) => {
@@ -848,6 +934,10 @@ impl Activation<'_> {
848934
) -> Result<Flow> {
849935
let limits = self.rt.config.kv;
850936
let refused = self.rt.store().write(|tx| {
937+
if let Some(reason) = crate::revocation::dispatch_check(tx, &self.lease.run_id)? {
938+
self.record_refusal(tx, flow_id, call, &Refusal::new("version_revoked", reason))?;
939+
return Ok(true);
940+
}
851941
if install::is_disabled(tx, flow_id)? {
852942
self.record_refusal(tx, flow_id, call, &disabled_refusal())?;
853943
return Ok(true);
@@ -891,6 +981,13 @@ impl Activation<'_> {
891981
}
892982
})?;
893983
self.rt.shared.signal.bump();
984+
if let Some(reason) = self
985+
.rt
986+
.store()
987+
.read(|c| crate::revocation::dispatch_check(c, &self.lease.run_id))?
988+
{
989+
return self.finish_revoked_call(call, reason);
990+
}
894991
Ok(Flow::Continue)
895992
}
896993

@@ -901,8 +998,12 @@ impl Activation<'_> {
901998
new: &NewCall<'_>,
902999
audited_at: i64,
9031000
) -> Result<Flow> {
904-
let (settlement, value, clock) = self.sync_value(&call.kind)?;
9051001
let outcome = self.rt.store().write(|tx| {
1002+
if let Some(reason) = crate::revocation::dispatch_check(tx, &self.lease.run_id)? {
1003+
self.record_refusal(tx, flow_id, call, &Refusal::new("version_revoked", &reason))?;
1004+
return Ok(Err(reason));
1005+
}
1006+
let (settlement, value, clock) = self.sync_value(&call.kind)?;
9061007
journal::insert_call(tx, &self.lease, flow_id, new)?;
9071008
audit::record(
9081009
tx,
@@ -921,9 +1022,13 @@ impl Activation<'_> {
9211022
(c, _) => c,
9221023
};
9231024
let value = clock.map_or(value.clone(), number_text);
924-
journal::record_sync(tx, &self.lease, call.position, settlement, &value, clock)
1025+
journal::record_sync(tx, &self.lease, call.position, settlement, &value, clock).map(Ok)
9251026
})?;
9261027
self.next_position += 1;
1028+
let outcome = match outcome {
1029+
Ok(outcome) => outcome,
1030+
Err(reason) => return self.finish_revoked_call(call, reason),
1031+
};
9271032
self.at(Boundary::SyncCommitted {
9281033
position: call.position,
9291034
})?;
@@ -1290,6 +1395,10 @@ impl Activation<'_> {
12901395
prepared: &Prepared,
12911396
now_ms: i64,
12921397
) -> Result<Option<String>> {
1398+
if let Some(reason) = crate::revocation::dispatch_check(tx, &self.lease.run_id)? {
1399+
self.record_refusal(tx, flow_id, call, &Refusal::new("version_revoked", reason))?;
1400+
return Ok(None);
1401+
}
12931402
let rate = self.rt.config.rate;
12941403
if install::is_disabled(tx, flow_id)? {
12951404
self.record_refusal(tx, flow_id, call, &disabled_refusal())?;
@@ -1383,8 +1492,19 @@ impl Activation<'_> {
13831492
call.position
13841493
));
13851494
}
1386-
let (settlement, value, clock) = self.sync_value(&call.kind)?;
13871495
let outcome = self.rt.store().write(|tx| {
1496+
if let Some(reason) = crate::revocation::dispatch_check(tx, &self.lease.run_id)? {
1497+
journal::accept_outcome(
1498+
tx,
1499+
&self.lease.run_id,
1500+
call.position,
1501+
None,
1502+
&kv::rejection("version_revoked", &reason),
1503+
Source::Local,
1504+
)?;
1505+
return Ok(Err(reason));
1506+
}
1507+
let (settlement, value, clock) = self.sync_value(&call.kind)?;
13881508
// Recovery must use the same monotonic clock rule as a new call.
13891509
let clock = match clock {
13901510
Some(sample) => {
@@ -1393,8 +1513,12 @@ impl Activation<'_> {
13931513
None => None,
13941514
};
13951515
let value = clock.map_or(value.clone(), number_text);
1396-
journal::record_sync(tx, &self.lease, call.position, settlement, &value, clock)
1516+
journal::record_sync(tx, &self.lease, call.position, settlement, &value, clock).map(Ok)
13971517
})?;
1518+
let outcome = match outcome {
1519+
Ok(outcome) => outcome,
1520+
Err(reason) => return self.finish_revoked_call(&call, reason),
1521+
};
13981522
self.at(Boundary::SyncCommitted {
13991523
position: call.position,
14001524
})?;
@@ -1431,6 +1555,14 @@ impl Activation<'_> {
14311555
self.readiness_seen = readiness;
14321556
if let Some(outcome) = outcome {
14331557
let position = outcome.position;
1558+
if outcome.settlement == Settlement::Rejected
1559+
&& serde_json::from_str::<Value>(outcome.value.as_str())
1560+
.ok()
1561+
.is_some_and(|v| v["code"] == "version_revoked")
1562+
{
1563+
let _ = self.send(&ParentMessage::Deliver(outcome));
1564+
return self.end_revoked("version revoked before dispatch".into());
1565+
}
14341566
self.at(Boundary::OrderCommitted {
14351567
position,
14361568
order: outcome.delivery_order,
@@ -1484,6 +1616,13 @@ impl Activation<'_> {
14841616
}
14851617

14861618
fn on_finished(&self, result: ActivationResult) -> Result<Flow> {
1619+
if let Some(reason) = self
1620+
.rt
1621+
.store()
1622+
.read(|c| crate::revocation::dispatch_check(c, &self.lease.run_id))?
1623+
{
1624+
return self.end_revoked(reason);
1625+
}
14871626
match result {
14881627
ActivationResult::Completed { value } => {
14891628
let exit = Exit::Succeeded {

‎crates/basal-core/src/error.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ pub enum CoreError {
3737
Worker(String),
3838
/// A request to the core was malformed.
3939
Invalid(String),
40+
/// Core could not answer a status read; retrying the operation is safe.
41+
Unavailable(String),
4042
/// The run cannot start yet: an earlier run of its flow has not reached
4143
/// a terminal state and holds the flow's concurrency slot.
4244
SlotBusy {
@@ -68,6 +70,7 @@ impl fmt::Display for CoreError {
6870
Self::Corrupt(e) => write!(f, "corrupt store value: {e}"),
6971
Self::Worker(e) => write!(f, "worker: {e}"),
7072
Self::Invalid(e) => write!(f, "invalid request: {e}"),
73+
Self::Unavailable(e) => write!(f, "core unavailable: {e}"),
7174
Self::SlotBusy { run_id, holder } => {
7275
write!(
7376
f,

‎crates/basal-core/src/gate.rs‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//! still stands behind the run's flow version.
33
//!
44
//! When the operator revokes a flow version in core, core refuses that
5-
//! version's sink writes, but nothing else would tell basal. So before every
5+
//! version's sink writes and sends basal a verified revocation hint. Before every
66
//! activation (a run's first, and every resume after a suspension or a
77
//! restart) the activation asks core's `flow.install_status` through the
88
//! host, and goes on only when core answers `active` with the code hash of
@@ -24,11 +24,10 @@
2424
//! turns into a loop of questions.
2525
//!
2626
//! The check runs after the activation's lease is taken and before the
27-
//! worker is handed anything. A revoke that lands after the check is not
28-
//! seen until the next activation asks again; until then core's own refusal
29-
//! of the revoked version's sink writes (digest items and status lines, the
30-
//! only way a flow reaches an agent) still holds, so the window is
31-
//! deliberately bounded by one activation.
27+
//! worker is handed anything. A verified push can record revocation during an
28+
//! activation: later dispatch attempts are refused, including local calls.
29+
//! Attempts authorized before that record may still be sent and settle.
30+
//! No revocation polling runs while idle; this gate also covers missed pushes.
3231
3332
use std::time::Duration;
3433

‎crates/basal-core/src/journal.rs‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -781,8 +781,9 @@ pub fn record_unknown(
781781
}
782782
tx.execute(
783783
"UPDATE runs SET state = 'needs_reconcile', awaited = NULL, \
784-
error_kind = 'unknown_outcome', error_detail = ?2 \
785-
WHERE run_id = ?1 AND state IN ('pending', 'suspended')",
784+
error_kind = 'unknown_outcome', error_detail = ?2, ended_at = NULL \
785+
WHERE run_id = ?1 AND (state IN ('pending', 'suspended') \
786+
OR (state='failed' AND error_kind='version_revoked'))",
786787
params![run_id, format!("unknown outcome at position {position}")],
787788
)?;
788789
tx.execute(
@@ -801,8 +802,9 @@ pub fn record_unknown(
801802
/// repeated report is `Duplicate`, and a report for a cancelled run is
802803
/// `Refused`. Otherwise the call is marked unknown and a pending or
803804
/// suspended run goes to `needs_reconcile` naming the detail; a run already
804-
/// there gets the detail appended to its error. A run that has ended keeps
805-
/// its state, with the call left visible as an unknown obligation.
805+
/// there gets the detail appended to its error. A revoked run also goes to
806+
/// `needs_reconcile`: stopping dispatch cannot resolve an earlier mutation.
807+
/// Other ended runs keep their state and the unknown obligation.
806808
///
807809
/// Returns `None` while the run is `running`: an activation owns its state
808810
/// then, so the host must report again later.
@@ -852,8 +854,9 @@ pub fn record_host_unknown(
852854
)?;
853855
tx.execute(
854856
"UPDATE runs SET state = 'needs_reconcile', awaited = NULL, \
855-
error_kind = 'unknown_outcome', error_detail = ?2 \
856-
WHERE run_id = ?1 AND state IN ('pending', 'suspended')",
857+
error_kind = 'unknown_outcome', error_detail = ?2, ended_at = NULL \
858+
WHERE run_id = ?1 AND (state IN ('pending', 'suspended') \
859+
OR (state='failed' AND error_kind='version_revoked'))",
857860
params![run_id, detail],
858861
)?;
859862
// Another call of the run may already have put it in needs_reconcile;

‎crates/basal-core/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ pub mod packages;
6060
pub mod rate;
6161
pub mod reconcile;
6262
pub mod retention;
63+
mod revocation;
6364
pub mod runs;
6465
pub mod runtime;
6566
pub mod schedule;

‎crates/basal-core/src/ops.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ pub fn flow_health(conn: &Connection, now_ms: i64) -> Result<Vec<FlowHealth>> {
152152
match run.state.as_str() {
153153
"failed" if readiness_failure => {}
154154
"failed" if run.error_kind.as_deref() == Some("agent_retired") => {}
155+
"failed" if run.error_kind.as_deref() == Some("version_revoked") => {}
155156
"failed" | "engine_mismatch" => consecutive_failures += 1,
156157
"succeeded" => counting = false,
157158
_ => {}

0 commit comments

Comments
 (0)