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
3 changes: 3 additions & 0 deletions .claude/board/AGENT_LOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## 2026-06-21 (cont.³¹) — S4 owner-advance SHIPPED: real ractor KanbanActor (first true OUT-leg wire)

**Main thread (Opus), self-directed ("go").** First REAL OUT-leg code (not docs/stubs), enabled by the cont.³⁰ unblock. New `lance-graph-supervisor::kanban_actor` (gated `supervisor`): `KanbanActor<O: MailboxSoaOwner>` — a ractor actor whose `State` IS the owner (mailbox-as-owner, per operator "every SoA is ractor-owned"). On `KanbanMsg::Advance { to, reply }` the owner advances ITSELF via the contract's checked `try_advance_phase` (single-writer by construction — ractor serializes one message at a time, so `&mut state` can't alias = the E-CE64-MB-4 no-race guarantee, realized by `&mut` + mailbox, no lock); illegal Rubicon edge → typed `RubiconTransitionError`, no mutation. `KanbanMsg::Phase` reads back. **2 tests green** (`actor_advances_its_own_phase_on_message`, `illegal_edge_is_a_typed_error_no_mutation`) under `--features supervisor`; clippy clean, fmt clean; light build (no lance/datafusion, no disk/symbiont gate). This is the owner-advance HALF of S4 (the mechanism the operator named). **Remaining S4:** the delivery edge (`kanban.*` → `where_is` → `cast(Advance)`) + the S2/S3 drivers that send `Advance` (those compose on top). Plan S4 row annotated "owner-advance HALF shipped". Rides a PR on jirak.
## 2026-06-21 (cont.³⁰) — UNBLOCKED the ractor-actor path (supervisor) — stale "BLOCKED" was a Cargo.lock pin, not a fork bug

**Main thread (Opus), self-directed ("continue").** Chasing the cheapest REAL OUT-leg progress, found `lance-graph-supervisor/Cargo.toml` documented a hard `BLOCKED (2026-06-14)`: "ractor fork ~2 commits behind upstream, non-exhaustive `MessagingErr::Saturated` match, `--features supervisor` will NOT compile." **That note is STALE — verified + fixed:** (1) `/home/user/ractor` (= origin/main, 0 ahead) already has `2bc7819 fix: handle MessagingErr::Saturated at all three match sites`; the enum is NOT `#[non_exhaustive]` and `derived_actor.rs` handles all 4 variants; `cargo check -p ractor --no-default-features --features tokio_runtime` is green. (2) The supervisor build still failed only because `Cargo.lock` pinned a PRE-fix git rev (`3f86d0a`, the merge commit before the all-sites fix). `cargo update -p ractor` advanced it `3f86d0a → f4c474f4` (fixed main); **`cargo check -p lance-graph-supervisor --features supervisor` now compiles clean** (verified, warnings only — callcenter's lance/datafusion are optional + off, so the build is light, no disk blowup). Net: the ractor-actor path (the **mailbox-as-owner substrate the OUT-leg S4/run-NaN depend on**) is OPEN. Committed: Cargo.lock (ractor rev), supervisor Cargo.toml (BLOCKED→RESOLVED comment). This is the first real unblock of the OUT-leg without needing the heavy planner build or symbiont. Rides a PR on jirak.
Expand Down
12 changes: 11 additions & 1 deletion .claude/plans/capstone-out-leg-wiring-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,17 @@ is consumption only.)

## S4 — envelope route gets a `Kanban` handler (resolve-then-reject → accept)

**Census state:** GAP. `kanban.*` resolves to `StepDomain::Kanban` then every
**Status (2026-06-21): owner-advance HALF shipped.** `lance-graph-supervisor::
kanban_actor::KanbanActor<O: MailboxSoaOwner>` is a real ractor actor whose
`State` IS the owner; on `KanbanMsg::Advance` it calls `try_advance_phase`
(owner advances itself; illegal edge → typed `RubiconTransitionError`, no
mutation). 2 tests green under `--features supervisor` (light build, no
disk/symbiont gate). **Remaining:** the *delivery edge* — `kanban.*` step →
mailbox id → `ractor::registry::where_is` → `cast(Advance)` — plus the S2/S3
drivers that send `Advance`. The mechanism the operator described ("every SoA
is ractor-owned, the owner advances itself") is now real code.

**Census state (original):** GAP. `kanban.*` resolves to `StepDomain::Kanban` then every
bridge impl returns `DomainUnavailable` (`orchestration_impl.rs:55-57`,
`codec_bridge.rs:38-40`). No handler.

Expand Down
223 changes: 223 additions & 0 deletions crates/lance-graph-supervisor/src/kanban_actor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
//! S4 — the kanban-advance ractor actor (the smallest *true* OUT-leg wire).
//!
//! Per the operator ownership model ("every SoA is owned by its ractor actor —
//! mailbox-as-owner"), the Rubicon phase of a per-mailbox SoA is advanced by the
//! actor that OWNS it, in reaction to a message. There is no owner-registry held
//! by a bridge and no "absent owner" case: the actor's `State` IS the owner
//! ([`MailboxSoaOwner`]).
//!
//! ## Why this is the safe substrate
//!
//! A ractor actor processes ONE message at a time, so the owner sees a strict
//! single-writer: `&mut state` during `handle` cannot alias another writer. That
//! is the compile-time "no aliasing / no data race / no use-after-free" guarantee
//! the canon attributes to mailbox-as-owner (E-CE64-MB-4) — realized here by
//! Rust's `&mut` + ractor's serialized mailbox, not by a lock.
//!
//! ## What it does NOT do (kept honest)
//!
//! This is the OWNER-advance mechanism only. It does NOT resolve a `kanban.*`
//! `UnifiedStep` to a mailbox (that delivery edge is `step_type` → mailbox id →
//! `ractor::registry::where_is` → `cast`, a separate seam) and it does NOT drive
//! the advance from a MUL gate (S2) or a Lance version tick (S3) — those compose
//! ON TOP by sending [`KanbanMsg::Advance`]. The owner advances **itself** via
//! the contract's checked [`MailboxSoaOwner::try_advance_phase`]; an illegal
//! Rubicon edge is a typed [`RubiconTransitionError`], never silent corruption.

use lance_graph_contract::kanban::{KanbanColumn, KanbanMove, RubiconTransitionError};
use lance_graph_contract::soa_view::MailboxSoaOwner;
use ractor::{Actor, ActorProcessingErr, ActorRef, RpcReplyPort};

/// Messages the kanban actor accepts.
pub enum KanbanMsg {
/// Advance the owned mailbox's Rubicon phase to `to` (checked against the
/// lifecycle DAG). Replies with the emitted [`KanbanMove`] on a legal edge,
/// or a [`RubiconTransitionError`] on an illegal one (no mutation occurs).
Advance {
to: KanbanColumn,
reply: RpcReplyPort<Result<KanbanMove, RubiconTransitionError>>,
},
/// Read the owned mailbox's current Rubicon phase (no mutation).
Phase { reply: RpcReplyPort<KanbanColumn> },
}

/// A ractor actor whose `State` IS a [`MailboxSoaOwner`] — the SoA mailbox and
/// its owning actor are the same thing (mailbox-as-owner). On
/// [`KanbanMsg::Advance`] the owner advances its own phase via
/// [`MailboxSoaOwner::try_advance_phase`].
pub struct KanbanActor<O: MailboxSoaOwner> {
_marker: core::marker::PhantomData<O>,
}

impl<O: MailboxSoaOwner> Default for KanbanActor<O> {
fn default() -> Self {
Self {
_marker: core::marker::PhantomData,
}
}
}

impl<O> Actor for KanbanActor<O>
where
O: MailboxSoaOwner + Send + Sync + 'static,
{
type Msg = KanbanMsg;
type State = O;
type Arguments = O;

async fn pre_start(
&self,
_myself: ActorRef<Self::Msg>,
owner: Self::Arguments,
) -> Result<Self::State, ActorProcessingErr> {
// The actor takes ownership of the SoA mailbox at spawn. From here on the
// ONLY mutator of this owner is this actor's serialized message loop.
Ok(owner)
}

async fn handle(
&self,
_myself: ActorRef<Self::Msg>,
msg: Self::Msg,
state: &mut Self::State,
) -> Result<(), ActorProcessingErr> {
match msg {
KanbanMsg::Advance { to, reply } => {
// Single-writer by construction: one message at a time. The owner
// advances ITSELF; nothing else holds it.
let result = state.try_advance_phase(to);
Comment on lines +85 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject stale phase-advance messages

When Advance is derived from a planned/scheduled KanbanMove, this handler only receives the target column and re-validates it against the actor's current phase. If another queued advance runs first, targets that are legal from multiple phases (notably Prune in KanbanColumn::next_phases, from both Planning and Evaluation) are accepted as a different transition instead of rejected, so a stale veto can prune the mailbox at the wrong lifecycle point. Carry and check the expected from phase (or the whole KanbanMove) before calling try_advance_phase.

Useful? React with 👍 / 👎.

let _ = reply.send(result);
}
KanbanMsg::Phase { reply } => {
let _ = reply.send(state.phase());
}
}
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use lance_graph_contract::collapse_gate::MailboxId;
use lance_graph_contract::kanban::ExecTarget;
use lance_graph_contract::soa_view::MailboxSoaView;

/// Minimal in-RAM owner (mirrors the contract's `FakeSoa`) — proves the actor
/// owns and advances a real `MailboxSoaOwner` without any heavy SoA backing.
struct TestBoard {
id: MailboxId,
phase: KanbanColumn,
cycle: u32,
}

impl MailboxSoaView for TestBoard {
fn mailbox_id(&self) -> MailboxId {
self.id
}
fn n_rows(&self) -> usize {
0
}
fn w_slot(&self) -> u8 {
(self.id & 0x3F) as u8
}
fn current_cycle(&self) -> u32 {
self.cycle
}
fn phase(&self) -> KanbanColumn {
self.phase
}
fn energy(&self) -> &[f32] {
&[]
}
fn edges_raw(&self) -> &[u64] {
&[]
}
fn meta_raw(&self) -> &[u32] {
&[]
}
fn entity_type(&self) -> &[u16] {
&[]
}
}

impl MailboxSoaOwner for TestBoard {
fn advance_phase(&mut self, to: KanbanColumn) -> KanbanMove {
let from = self.phase;
self.phase = to;
self.cycle = self.cycle.wrapping_add(1);
KanbanMove {
mailbox: self.id,
from,
to,
witness_chain_position: self.cycle,
libet_offset_us: 0,
exec: ExecTarget::Native,
}
}
}

fn board(phase: KanbanColumn) -> TestBoard {
TestBoard {
id: 42,
phase,
cycle: 0,
}
}

#[tokio::test]
async fn actor_advances_its_own_phase_on_message() {
let (actor, handle) = Actor::spawn(
None,
KanbanActor::<TestBoard>::default(),
board(KanbanColumn::Planning),
)
.await
.expect("spawn kanban actor");

// Legal forward arc Planning -> CognitiveWork: the owner advances itself.
let mv = ractor::call!(actor, |reply| KanbanMsg::Advance {
to: KanbanColumn::CognitiveWork,
reply
})
.expect("rpc")
.expect("legal Rubicon edge");
assert_eq!(mv.from, KanbanColumn::Planning);
assert_eq!(mv.to, KanbanColumn::CognitiveWork);

// The advance persisted in the owned SoA.
let phase = ractor::call!(actor, |reply| KanbanMsg::Phase { reply }).expect("rpc");
assert_eq!(phase, KanbanColumn::CognitiveWork);

actor.stop(None);
handle.await.expect("actor join");
}

#[tokio::test]
async fn illegal_edge_is_a_typed_error_no_mutation() {
let (actor, handle) = Actor::spawn(
None,
KanbanActor::<TestBoard>::default(),
board(KanbanColumn::Planning),
)
.await
.expect("spawn kanban actor");

// Planning -> Commit is NOT a legal Rubicon edge: typed error, no mutation.
let err = ractor::call!(actor, |reply| KanbanMsg::Advance {
to: KanbanColumn::Commit,
reply
})
.expect("rpc")
.expect_err("illegal edge must be rejected");
assert_eq!(err.from, KanbanColumn::Planning);
assert_eq!(err.to, KanbanColumn::Commit);

// Phase unchanged — the owner did not mutate on the rejected edge.
let phase = ractor::call!(actor, |reply| KanbanMsg::Phase { reply }).expect("rpc");
assert_eq!(phase, KanbanColumn::Planning);

actor.stop(None);
handle.await.expect("actor join");
}
}
8 changes: 8 additions & 0 deletions crates/lance-graph-supervisor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ pub mod supervisor;
#[cfg(feature = "supervisor")]
pub mod actors;

/// S4 OUT-leg: the kanban-advance actor (mailbox-as-owner; the owner advances
/// its own Rubicon phase on a message).
#[cfg(feature = "supervisor")]
pub mod kanban_actor;

#[cfg(feature = "supervisor")]
pub use kanban_actor::{KanbanActor, KanbanMsg};

#[cfg(feature = "supervisor")]
pub use supervisor::{
CallcenterSupervisor, ChildSummary, ConsumerSlot, ModuleEntry, StubConsumerActor,
Expand Down
Loading