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
14 changes: 13 additions & 1 deletion crates/tinyhivemind-driver/src/conduct/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,19 @@ The rules, each with the decision it comes from:
conversation waits with it rather than concluding for want of a turn.
Nothing due with a seat parked is a wait, not a stall; `parked` says who,
and `resume_seat` puts the seat back in the next wave, owed a turn where
it parked. `Event::Parked` and `Event::Resumed` mark both.
it parked. `Event::Parked` and `Event::Resumed` mark both. A held seat
also keeps `finished` false even where its own work closed some other way,
because ending the episode there would strand whatever the host queued to
hold it: the operator answers an approval with no loop to return to.
- **Checkpointing**: `snapshot` carries the wave in progress as well as the
episode, so a host checkpoints after **every committed row** rather than
once per wave, and a crash replays at most the one row whose sequence had
not been reported yet. It answers `None` only while the host holds a
commit it has not reported through `committed`: the conductor cannot say
whether that row landed, so it writes no claim either way. `resume`
rebuilds from a snapshot on a driver and a routing the host supplies
again, and a caller drains `step` before proposing a new wave -- a
restored wave is dropped otherwise, because `begin_wave` resets the phase.
- **Sorting**: a broadcast or an ask made inside a conversation is desk
work; only a post or a completion is a row of the conversation.
- **Refusals**: a completion the ledger refuses is explained to the seat on
Expand Down
9 changes: 6 additions & 3 deletions crates/tinyhivemind-driver/src/conduct/child.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! One open conversation: a thread of the desk, run as its own episode.

use serde::{Deserialize, Serialize};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority critical critique confident

Use the existing resume API before importing child state

This import enables direct deserialization of Child, whose embedded DriverState must not be trusted as-is. The existing driver resume API is the validation boundary; ensure deserialized children are passed through it before they become conductor state rather than importing them directly.

[RULE] validated-import ·

use tinyhivemind::Sequence;

use crate::driver::{ConversationView, DriverState};
Expand All @@ -8,7 +9,7 @@ use crate::driver::{ConversationView, DriverState};
/// with the asker recorded here (ADR 0023). One question, one answer: the
/// seat asked concludes with `complete_episode`, and its message is the
/// answer; a follow-up is a further ask.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Deserialize, Serialize)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority critical security confident

Use an existing resume API before importing child state

Adding Deserialize makes Child.state caller-controlled snapshot data, but conductor restoration validates only the top-level state. A crafted child can therefore name an out-of-hive desk or participant and be used in later waves without the driver's checks. Validate each child state with driver.resume before inserting it into the conductor.


Additional critique observation

priority medium confident

Validate every child before restoring it

[RULE] validate-restored-state

Deriving Deserialize makes each child, including its embedded DriverState, restorable from a snapshot. The conductor's resume path validates only the top-level state and copies child states directly, so a crafted or stale snapshot can restore a child naming another desk or an unknown participant. Validate each child with the driver's resume API before inserting it into the conductor.


Additional critique observation

priority medium confident

Validate every child state before restoring it

[RULE] validate-deserialized-state

The newly deserializable child contains caller-controlled episode, identity, receipt, and ledger state. Restoring it without passing it through CompletionDriver::resume bypasses the validation contract and allows invalid child state to be used on later waves. Route every child state through the existing resume API and reject the snapshot if any child fails validation.


Additional security observation

priority medium confident

Validate every child before restoring the conductor

[RULE] validate-restored-state

This newly deserializable child carries its own DriverState, participant identities, desk identity, and root. Restoring it without driver.resume bypasses the validation performed for the top-level episode, allowing stale or tampered child conversations into subsequent waves. Validate every child before constructing the conductor.


Additional security observation

priority medium confident

Validate every child state before restoring it

[RULE] validate-restored-state

Because Child is now deserialized, its embedded DriverState is untrusted input. The existing conductor resume path validates only snapshot.state; an invalid child state can bypass that check and later drive turns for an unknown desk or seat. Run every child state through the driver's resume validation before accepting the snapshot.


Additional security observation

priority medium confident

Validate every child before restoring it

[RULE] validate-restored-state

The new deserialization boundary permits a snapshot to supply child identities and state independently of the top-level state. Without validating each child, a caller can restore a child naming another desk or unknown participant, or otherwise inconsistent conversation state, and the conductor will operate on it in a later wave. Reject invalid children before restoration.

[RULE] validate-deserialized-state ·

pub(super) struct Child {
pub(super) root: Sequence,
pub(super) asker: String,
Expand All @@ -20,7 +21,9 @@ pub(super) struct Child {
pub(super) last_by_askee: Option<String>,
/// Whether the seat asked has been told once that it has not answered.
pub(super) nudged: bool,
/// Whether the seat asked took a turn in this wave.
/// Whether the seat asked took a turn in this wave. Carried across a
/// snapshot: a wave can be resumed mid-flight, and dropping this would
/// lose the nudge owed to a seat that was asked and said nothing.
pub(super) turned: bool,
}

Expand Down Expand Up @@ -82,7 +85,7 @@ impl Child {

/// A conversation that concluded, kept for the context of the seats that had
/// it: shown whole once to each, on its next desk turn.
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique confident

Validate every child before restoring the conductor

Concluded conversations are also newly deserializable and carry participant identities and a root sequence. Restoring these records without checking that their participants and root agree with the supplied hive can leave the conductor with an invalid conversation context. Validate each restored child record and its consistency with the snapshot key before constructing the conductor.

[RULE] validate-restored-state ·

pub(super) struct Concluded {
pub(super) root: Sequence,
pub(super) asker: String,
Expand Down
197 changes: 196 additions & 1 deletion crates/tinyhivemind-driver/src/conduct/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ mod wave;

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use tinyhivemind::speech::{ToolCall, Utterance};
use tinyhivemind::{Conversation, Sequence};
use tinyhivemind_embed::RoutingPlan;
Expand Down Expand Up @@ -110,6 +112,66 @@ pub fn starters(plan: &RoutingPlan, fallback: &str) -> Vec<String> {
}
}

/// Everything a conductor needs to be rebuilt: the episode as the driver
/// folds it, the conversations open and concluded, and what each seat has
/// been shown, nudged for, or held on.
///
/// Carries the wave in progress too, so a snapshot is exact rather than
/// per-wave: a host checkpoints after every committed row, and a crash
/// replays at most the one row whose sequence had not been reported yet.
/// The only point a snapshot cannot be taken is while the host holds a
/// commit it has not reported -- the conductor does not know whether that
/// row landed -- and [`Conductor::snapshot`] answers `None` there.
///
/// The driver, the routing and the policy are **not** here. They are the
/// host's to supply again on resume, exactly as they were on open: a router
/// is a live object, and a policy the operator changed between restarts
/// should be the new one.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ConductorState {
/// The desk's id, as every tool call names it.
pub chat: String,
/// The desk's display name.
pub desk_name: String,
/// The episode the driver folds.
pub state: DriverState,
/// The conversations still open, by their ask row.
children: Vec<(Sequence, Child)>,
/// The conversations that concluded, oldest first.
concluded: Vec<Concluded>,
/// How many concluded conversations each seat has been shown.
shown: BTreeMap<String, usize>,
/// The assignment each seat was last nudged for on the desk.
desk_nudged: BTreeMap<String, Sequence>,
/// Seats held on the host, by the thread they parked in.
parked: BTreeMap<String, Option<Sequence>>,
/// Turns run so far.
turns: u64,
/// Waves proposed so far.
waves: u64,
/// Seats completed with their work for a spent broadcast budget.
discharged: u64,
/// The wave in progress: what has been said and not yet committed, and
/// the steps the host has not taken. Empty between waves.
wave: Wave,
}

impl ConductorState {
/// Whether this snapshot was taken between waves, with nothing said and
/// nothing left for the host to do.
///
/// A host does not need this -- [`resume_episode`] drains whatever the
/// wave holds either way -- but it is the difference between a restart
/// that lost a whole wave and one that lost a row.
///
/// [`resume_episode`]: https://docs.rs/tinyhivemind-openhuman
#[must_use]
pub fn mid_wave_is_empty(&self) -> bool {
self.wave.is_idle()
}
}

/// The desk episode, its conversations, and the rules between them.
pub struct Conductor<'a, A: BoundAgent> {
driver: &'a CompletionDriver<'a, A>,
Expand Down Expand Up @@ -214,7 +276,12 @@ impl<'a, A: BoundAgent> Conductor<'a, A> {
/// Over: the desk is quiescent and no conversation is open.
#[must_use]
pub fn finished(&self) -> bool {
self.state.quiescent() && self.children.is_empty()
// A parked seat is not a finished one, even where its work closed
// some other way -- a broadcast it made in the same turn spending
// the budget, say. Ending the episode there would strand whatever
// the host queued to hold it: the operator answers an approval that
// no longer has a loop to return to.
self.state.quiescent() && self.children.is_empty() && self.parked.is_empty()
}

/// The desk episode's state.
Expand Down Expand Up @@ -358,6 +425,134 @@ impl<'a, A: BoundAgent> Conductor<'a, A> {
Ok(turns)
}

/// Everything needed to rebuild this conductor, or `None` while the
/// host holds a commit it has not reported.
///
/// A host checkpoints after every committed row. The wave in progress
/// travels with the snapshot, so a crash replays at most that one row:
/// the conductor comes back mid-wave with the same seats having spoken
/// and the same steps still to take.
///
/// `None` means the host is holding a commit whose sequence it has not
/// reported through [`committed`](Self::committed). The conductor cannot
/// say whether that row reached the journal, so it will not write down a
/// claim either way; the caller reports the sequence and asks again.
#[must_use]
pub fn snapshot(&self) -> Option<ConductorState> {
if !self.wave.recordable() {
return None;
}
Some(ConductorState {
chat: self.chat.clone(),
desk_name: self.desk_name.clone(),
state: self.state.clone(),
children: self
.children
.iter()
.map(|(root, child)| (*root, child.clone()))
.collect(),
concluded: self.concluded.clone(),
shown: self.shown.clone(),
desk_nudged: self.desk_nudged.clone(),
parked: self.parked.clone(),
turns: self.turns,
waves: self.waves,
discharged: self.discharged,
wave: self.wave.clone(),
})
}

/// Rebuild a conductor from a snapshot, on a driver and a routing the
/// host supplies again.
///
/// The episode carries on where it paused: the same conversations are
/// open, the same seats are held, a seat already nudged for its
/// assignment is not nudged again for it, and a wave that was in
/// progress resumes mid-wave. A caller drains
/// [`step`](Self::step) before proposing a new wave, or the restored
/// steps are dropped.
///
/// # Errors
///
/// The driver refusing the episode or any of its conversations -- a
/// state naming a seat or a desk this hive does not have -- and
/// [`Error::InconsistentSnapshot`] for a snapshot that disagrees with
/// itself: a conversation filed under the wrong root, a cursor past the
/// conversations it points into, or a seat held that this desk does not
/// seat.
pub fn resume(
driver: &'a CompletionDriver<'a, A>,
routing: BroadcastRouting<'a>,
policy: ConductPolicy,
snapshot: ConductorState,
) -> Result<Self> {
let state = driver.resume(snapshot.state)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique confident

Validate every child before restoring the conductor

driver.resume validates only snapshot.state; each Child contains its own DriverState, participant identities, desk identity, and root, but those values are inserted directly into children without validation or consistency checks. A caller can deserialize a snapshot whose child names another desk or unknown seat (or whose map key disagrees with child.root), and resume will still succeed before later turns operate on that invalid conversation. Validate every child state against the supplied driver and reject inconsistent child roots before constructing the conductor.


Additional security observation

priority medium likely

Validate every child state before restoring it

[RULE] validate-deserialized-state

driver.resume validates snapshot.state, but each Child contains its own DriverState and snapshot.children is copied directly into the conductor without validation. A deserialized or tampered snapshot can therefore introduce a child episode naming another desk or participant, bypassing the validation promised by resume and allowing invalid state to be used on subsequent waves. Validate every child state with driver.resume before constructing the conductor.

Suggested change for this observation (reference only)

let state = driver.resume(snapshot.state)?;
        let children = snapshot
            .children
            .into_iter()
            .map(|(root, mut child)| {
                child.state = driver.resume(child.state)?;
                Ok((root, child))
            })
            .collect::<Result<BTreeMap<_, _>>>()?;
        Ok(Self {
            driver,
            routing,

[RULE] validate-restored-state ·

// A snapshot is the host's file, not the conductor's memory: every
// part of it is validated here rather than trusted, because the
// alternative is an episode that resumes and then misbehaves waves
// later with nothing left to say why.
let mut children = BTreeMap::new();
for (root, mut child) in snapshot.children {
if root != child.root {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique confident

Derive the conversation root from the recorded state

The check only proves that the serialized map key matches child.root; driver.resume(child.state) validates the desk and participants, but it does not validate the child's conversation root. A snapshot can therefore use the same arbitrary root in the map key and child.root while the embedded child.state records a different thread root (or no thread root), and resume will accept an internally inconsistent child. Compare the key with the conversation root recorded in child.state before inserting the child.

[RULE] inconsistent-state-validation ·

return Err(Error::InconsistentSnapshot {
reason: format!(
"a conversation filed under {} calls itself {}",
root.0, child.root.0
),
});
}
// The same validation the desk episode gets: the conversation

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium security confident

Validate every child before restoring the conductor

driver.resume(child.state) validates the embedded driver state, but this loop never validates the child record's own root, asker, or askee against the restored episode and hive. A crafted snapshot can therefore restore a child whose metadata names seats or a conversation that does not match the desk state, and later conduct/briefing logic will operate on inconsistent participant identity. Validate each child record completely before inserting it, rejecting any root, asker, or askee that is not consistent with the episode and roster.

[RULE] validate-restored-state ·

// names this desk, and its two seats are seats of this hive.
child.state = driver.resume(child.state)?;
children.insert(root, child);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique confident

Reject duplicate conversation roots during restore

snapshot.children is deserialized as a Vec, so a malformed or edited snapshot can contain the same root more than once. BTreeMap::insert silently replaces the earlier child, losing an active conversation while resume still returns Ok, contrary to the documented self-consistency validation. Check whether insertion replaced an existing entry and return Error::InconsistentSnapshot instead.


Additional security observation

priority medium confident

Reject duplicate child roots during restore

[RULE] duplicate-state-key

Deserialization permits multiple entries with the same root, but BTreeMap::insert silently overwrites the earlier child. This makes the restored conductor depend on serialization order and can discard conversation state without an error. Reject duplicate roots instead of replacing an existing child.

[RULE] duplicate-key-overwrite ·

}
// A cursor into the concluded list, for a seat that has been shown
// some of them. Past the end it would panic the first time that
// seat is briefed.
for (seat, cursor) in &snapshot.shown {
if *cursor > snapshot.concluded.len() {
return Err(Error::InconsistentSnapshot {
reason: format!(
"@{seat} has been shown {cursor} conversations of {}",
snapshot.concluded.len()
),
});
}
}
// A seat held on the host that this desk does not seat would be held
// for ever: it is never proposed, and nothing can release it into a
// wave.
for seat in snapshot.parked.keys() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique likely

Validate every parked thread reference

Only the parked seat is validated here. A serialized snapshot can give a seated agent Some(root) for a root that is absent from snapshot.children (or belongs to another conversation), and resume still succeeds. The later release/resume path will then operate on a nonexistent or mismatched conversation; the omitted validation is especially risky because the parked map is newly restored from untrusted serialized state. Validate each Some(root) against the restored child map and its participants before constructing the conductor.

[RULE] unvalidated-reference ·

if !state
.episode()
.participants
.iter()
.any(|participant| &participant.agent_id == seat)
{
return Err(Error::InconsistentSnapshot {
reason: format!("@{seat} is held but is not seated at this desk"),
});
}
}
Ok(Self {
driver,
routing,
chat: snapshot.chat,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique confident

Validate the restored desk identity against driver state

driver.resume(snapshot.state) validates the desk represented by the driver state, but the independently serialized chat and desk_name are copied without comparison. A snapshot with a valid state and altered top-level identity therefore resumes successfully while subsequent conductor operations use a different desk id or display name than the validated episode. Reject mismatches before constructing Self.

[RULE] inconsistent-restored-identity ·

desk_name: snapshot.desk_name,
policy,
state,
children,
concluded: snapshot.concluded,
shown: snapshot.shown,
desk_nudged: snapshot.desk_nudged,
parked: snapshot.parked,
turns: snapshot.turns,
waves: snapshot.waves,
discharged: snapshot.discharged,
wave: snapshot.wave,
})
}

/// The seats held on the host, in seat order. An empty wave with any of
/// these is the host's to end: it releases one with
/// [`resume_seat`](Self::resume_seat), or gives up.
Expand Down
Loading
Loading