Skip to main content
Rust SDK | SubstrateCreate, control, and observe Substrate workspaces with builders, typed refusals, and an optional managed daemon child.Substratehow-tosubstratehow-toadopterdeveloperoperatorreference

Use Substrate from Rust

b10x-substrate-sdk is the high-level asynchronous Rust client. It speaks to the daemon over an owner-private Unix socket, verifies the daemon's advertised contract, and returns typed workspace, process, event, operation, and refusal observations.

The SDK and wire contract are development releases below 1.0. Current development source verifies the explicitly promoted substrate-wire/0.16.0 name and inner manifest digest before it serves an operation. Missing, older, unknown, and wrong-digest daemon claims are refused; a newer Rust type in the workspace does not by itself advance that pair.

Connect and run an argv-only command

Clone Substrate beside your application, then add the client and Tokio. b10x does not publish these crates to crates.io; a path dependency makes the source you are testing explicit:

[dependencies]
b10x-substrate-sdk = { path = "../substrate/crates/b10x-substrate-sdk" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
ulid = "3"

For a shared build, replace the path with an exact Git revision after that revision passes the repository gate. Do not use an unpinned branch and do not expect a crates.io package.

Then connect, create an empty workspace, and state every execution limit explicitly:

use std::time::Duration;

use b10x_substrate_sdk::{Client, ExecutionPolicy};

#[tokio::main]
async fn main() -> Result<(), b10x_substrate_sdk::SdkError> {
let client = Client::builder()
.unix_socket("run/substrate.sock")
.connect()
.await?;

let workspace = client
.workspace()
.empty()
.label("purpose", "example")
.create()
.await?;

workspace.write_file("input.txt", b"hello from Rust\n").await?;

let policy = ExecutionPolicy::builder()
.timeout(Duration::from_secs(15))
.cpu_time(Duration::from_secs(2))
.memory_bytes(64 * 1024 * 1024)
.processes(16)
.output_bytes(64 * 1024)
.build()?;

let output = workspace
.command("/usr/bin/sha256sum")
.arg("/workspace/input.txt")
.policy(policy)
.run()
.await?;

print!("{}", String::from_utf8_lossy(&output.stdout));
workspace.destroy().await?;
Ok(())
}

The command builder sends the argument vector as written. It does not invoke a shell or offer a shell-string shortcut. The policy builder has no defaults: wall time, cumulative CPU, memory plus swap, process count, and retained output must all be chosen by the application.

If the host cannot prove the required confinement, run() returns SdkError::Refusal with the daemon's canonical class, code, address, retry fact, and operation id. A non-zero program exit is instead a successful observation in RunOutput.

Connect to a remote daemon

Remote mode keeps the same Client, workspace, exec, event, metrics, and session handles. It adds four required trust inputs: the exact HTTPS origin, a PEM root bundle dedicated to that endpoint, the DNS identity expected in its certificate, and an asynchronous provider for short-lived Identity access credentials.

use b10x_substrate_sdk::{
AccessToken, AccessTokenReason, Client, SdkError,
};

async fn obtain_from_identity(
_reason: AccessTokenReason,
) -> Result<String, SdkError> {
// Call your deployment's Identity client or workload-identity broker here.
// Do not log or persist the returned opaque credential.
todo!()
}

let client = Client::builder()
// The endpoint host is the TCP destination and HTTP Host authority.
.https_endpoint("https://10.24.8.17:8443/")
.trust_roots("/etc/my-service/substrate-ca.pem")
// Certificate verification remains bound to this DNS name.
.server_identity("substrate.example.com")
.token_provider(|reason| async move {
AccessToken::new(obtain_from_identity(reason).await?)
})
.connect()
.await?;

let workspace = client.workspace().empty().create().await?;

This deliberately has no system-root default, redirect handling, environment proxy, plaintext fallback, credential store, or certificate-verification switch. The provider is called for each request and once more after a named authentication failure, so it can rotate an expired or revoked credential. A mutation keeps the same serialized body and operation id through that refresh.

event_stream, metrics_stream, and PipeSession::attach automatically use wss:// with the same roots and server identity. Each remote session attachment generates a new ephemeral signing key, mints a one-use authority, and binds its proof to the accepting TLS 1.3 channel. A disconnected attachment is terminal; calling attach again never replays the previous authority.

The token provider's failure text and credential bytes are never copied into SdkError. Hosted authentication and scope failures remain the daemon's typed refusals, while unknown roots and DNS name mismatches are transport errors before HTTP admission.

Preserve capability absence

Use the exact fact set when deciding whether to offer an operation. None means the daemon did not prove the guarantee; it does not mean a negative fact was observed.

let machine = client.machine();
if machine.facts.sessions_pty == Some(true) {
// A PTY request may now be attempted. Dispatch can still return a named refusal.
}

if let Some(usage) = machine.facts.exec_resource_usage {
println!("memory peak available: {}", usage.memory_peak);
} else {
println!("this deployment did not prove the complete usage counter set");
}

The older convenience booleans remain available, but they deliberately collapse information. Use Machine::facts for admission, schema projection, or an agent-facing adapter.

Drive a terminal and observe usage

PTY is a mode of the leased session resource, not a shell-string exec. State the initial window and all channel bounds, attach once, and resize through the typed channel:

use b10x_substrate_sdk::{ExecMeasurement, PtyWindow};

let session = workspace
.pty_session("/usr/bin/bash", PtyWindow { columns: 100, rows: 30 })
.policy(policy)
.measure(ExecMeasurement::ResourceUsage)
.lease(Duration::from_secs(30))
.input_limit_bytes(1024 * 1024)
.frame_limit_bytes(16 * 1024)
.queued_frames(8)
.start()
.await?;

let mut terminal = session.attach().await?;
terminal.resize(PtyWindow { columns: 120, rows: 40 }).await?;
terminal.write(b"printf 'hello from a pty\\n'\n").await?;

Request ExecMeasurement::ResourceUsage only when the exact fact is present. Then use Client::metrics for a point-in-time observation or Client::metrics_stream for latest-wins live samples. A host that cannot expose every declared counter keeps the fact absent and returns a named refusal; the SDK does not manufacture partial metrics.

Guard file changes and recovery

The development SDK covers the v2 compare-and-set byte plane and lets every mutation keep a caller operation id:

use b10x_substrate_sdk::ExpectedFileState;

let operation_id = ulid::Ulid::generate().to_string();
let changed = workspace
.replace_file(
"src/config.txt",
b"mode=confined\n",
ExpectedFileState::Absent,
true,
Some(operation_id.clone()),
)
.await?;

let recorded = client.operation(&operation_id).await?;
assert_eq!(recorded.id, operation_id);
println!("new digest: {}", changed.after_sha256);

read_file_v2 returns a bounded byte page plus the complete-file digest; tree returns a bounded recursive view. create_reconciliation_snapshot and reconciliation_snapshot_page provide a barriered recovery view after an event-history gap. Exec::output_page exposes the same explicit offset and byte limit instead of allocating an unbounded transcript.

Connect to a daemon you own

The default SDK does not start a daemon. If your application should supervise one, point it at the installed substrate-daemon binary and an explicit durable data directory:

use b10x_substrate_sdk::ManagedDaemon;

# async fn example() -> Result<(), b10x_substrate_sdk::SdkError> {
let mut daemon = ManagedDaemon::builder()
.data_dir("run/my-application-substrate")
.deployment("my_application")
.external_binary("/usr/local/bin/substrate-daemon")
.start()
.await?;

let client = daemon.client();
let machine = client.machine();
println!("capability snapshot: {}", machine.capability_snapshot);

daemon.shutdown().await?;
# Ok(())
# }

Managed mode always starts a separate child process. It admits only the invoking effective user, waits for a contract-verified readiness response, and owns shutdown and reaping. Explicit shutdown retains the state database and workspaces. Use .temporary() only when removal after shutdown is actually intended.

Ship one application executable

The optional linked-daemon feature links the daemon so the application can re-execute its own binary as the child. It does not run the service in-process.

[dependencies]
b10x-substrate-sdk = { path = "../substrate/crates/b10x-substrate-sdk", features = ["linked-daemon"] }

Call the child entrypoint before parsing your application's command line, then select linked mode:

use b10x_substrate_sdk::{ManagedDaemon, run_daemon_child_if_requested};

#[tokio::main]
async fn main() -> Result<(), b10x_substrate_sdk::SdkError> {
if run_daemon_child_if_requested().await? {
return Ok(());
}

let mut daemon = ManagedDaemon::builder()
.data_dir("run/my-application-substrate")
.deployment("my_application")
.linked_current_exe()
.start()
.await?;

// Resource operations still cross the authenticated Unix socket.
let _machine = daemon.client().machine();
daemon.shutdown().await
}

The parent holds a liveness pipe to the child. Dropping the owner initiates bounded shutdown; shutdown().await is preferred when the application needs the result. This model preserves peer credentials, socket permissions, independent failure, and the same wire behavior as an external daemon.

Recover and observe

Mutation builders mint one operation id unless you provide one. If a response is lost, the SDK queries the durable operation ledger and can replay the identical request once under that same id; it never substitutes a new id. SdkError::UnknownOperation carries the id needed for later reconciliation.

Use Client::events for bounded pages, Client::event_stream for the cursor-preserving WebSocket stream, and Client::operation to inspect a known mutation. A retention gap is a typed SdkError::EventGap; the SDK does not silently skip to current state.