Parley & Chronicle: Pause, Resume, History and Graceful Shutdown
Human-in-the-loop approval gates, typed resume, an inspectable and forkable execution history, and
cooperative shutdown for the WarEngine — built as thin, well-specified layers over the Waypoint
substrate the Control Flow guide introduces.
Table of Contents
- Building an Approval Gate
- Raising a Parley from a Paladin Node
- Resuming a Suspended Thread
- Partial Answers
- Expiry:
on_expirePolicies - Chronicle: History, Replay and Fork
- Graceful Shutdown from the Embedder's Side
- The HTTP Surface
- Notifying a Human a Parley Is Waiting
Building an Approval Gate
A Parley is a suspension point: a node stops the run, a Waypoint with status: AwaitingInput
is persisted, every task/timer/connection for the run is released, and the run returns
RunOutcome::AwaitingInput. The primary building block is NodeSpec::Gate — a node with no run
body of its own that always parleys on its first visit and writes the delivered value on the
post-resume visit.
An approval gate is exactly one Gate node plus two conditional edges — no custom node code:
use std::time::Duration;
use paladin_battalion::engine::graph::{GateRequestTemplate, NodeSpec, WarGraph};
use paladin_battalion::engine::{EdgeCondition, InputMapping};
use paladin_core::platform::container::battlefield::FieldName;
use paladin_core::platform::container::parley::{OnExpire, ParleyKind};
let approved = FieldName::new("approved").unwrap();
let request = GateRequestTemplate::new(
ParleyKind::Approval,
InputMapping::new("Deploy build {build_id} to production?"),
)
.with_payload_template(InputMapping::new(r#"{"build_id": "{build_id}"}"#))
.with_expires_in(Duration::from_secs(24 * 60 * 60))
.with_on_expire(OnExpire::FailRun);
graph.add_node("approve", NodeSpec::gate(request, Some(approved.clone())));
graph.add_edge("approve", "deploy", Some(EdgeCondition::Contains(r#""approved":true"#.into())));
graph.add_edge("approve", "cancel", Some(EdgeCondition::Contains(r#""approved":false"#.into())));
A few things to note:
output_field(approvedhere) is required forApproval/Choice/FreeTextgates and must beNonefor aStateEditgate —WarGraph::validaterejects every other combination, and checks the field exists in the schema with a type compatible with the gate'skind(Approval→BoolorString;Choice/FreeText→String).prompt_template/payload_templaterender from the Battlefield through the sameInputMappingtemplating every other node uses.Approvalvalues are normalised before delivery: JSONtrue/falseor the case-insensitive strings"yes"/"no"/"approve"/"deny"all resolve to a JSON boolean (or"true"/"false"ifoutput_fieldis aStringfield).- A
Gate'soutput_fieldis what edge evaluation reads for its source — exactly like a Paladin node's ownoutput_field— soContains/Regex/a registeredCustomevaluator all work unchanged. Anchor aContainsneedle to the full"field":valuepair (as above), not a baretrue/false—Contains/Regexmatch against the whole serialized Battlefield JSON, and every non-required schema field's own"required":falseentry contains the bare wordfalse.
Raising a Parley from a Paladin Node
A Paladin node can raise a parley without a declarative Gate, through the structured directive
envelope's next.parley key:
{
"delta": {},
"next": {
"parley": {
"kind": "Approval",
"prompt": "Deploy build #482 to production?",
"payload": { "build": 482 },
"expires_in_secs": 86400
}
}
}
The parser stamps parley_id, node_id and created_at, and computes expires_at from
expires_in_secs — an author never supplies these. kind/prompt are required; payload,
choices, expires_in_secs and on_expire are optional (on_expire defaults to FailRun).
On the post-resume re-run, InputMapping::render resolves the answer through a parley.
namespace — resolved only from NodeContext, never the Battlefield, exactly like the
muster. namespace the Control Flow guide documents:
// {parley.value} -- the submitted/defaulted value
// {parley.prompt} -- the originating request's own prompt
// {parley.kind} -- "Approval" | "Choice" | "FreeText" | "StateEdit"
// {parley.responded_by} -- the responder's identity, or empty for a defaulted response
WarGraph::validate rejects any schema field named with the parley. prefix, so a graph's own
state can never shadow this namespace. NodeContext::parley_response() gives the same data to
ordinary Rust code:
if let Some(response) = ctx.parley_response() {
// response.value, response.kind, response.responded_by, response.defaulted
}
Resuming a Suspended Thread
WarEngine::resume_with is the only path that advances a suspended thread:
use paladin_core::platform::container::parley::ParleyResponse;
let outcome = engine.resume_with(&graph, thread_id, responses).await?;
Every submitted ParleyResponse is validated totally before anything is persisted — an error
on any response leaves the thread suspended with no Waypoint written:
EngineError variant | Meaning |
|---|---|
ThreadNotAwaitingInput | A plain resume/resume_with_options against a suspended thread, or resume_with against a thread that is not AwaitingInput |
UnknownParleyId | The submitted parley_id does not match any outstanding request on this thread |
ParleyAlreadyAnswered | The parley_id already has an accepted response |
ResponseShapeInvalid { parley_id, reason } | The value does not match its request's kind (e.g. an Approval gate submitted "maybe") |
ParleyExpired { parley_id, expires_at } | expires_at has passed and on_expire: FailRun applies |
GraphMismatch | The graph passed to resume_with does not fingerprint-match the one the thread suspended under |
A plain WarEngine::resume (no responses) against an AwaitingInput thread never guesses — it
fails closed with EngineError::ThreadAwaitingInput, naming the still-outstanding parleys.
Partial Answers
When several nodes parley in the same superstep, all of their requests are recorded on one
AwaitingInput Waypoint, and all must be answered before the run continues. Submitting a
correct answer for only some of them persists a new AwaitingInput Waypoint at the same
superstep, with responses extended and RunOutcome::AwaitingInput naming only the still-remaining
requests:
// Two parleys outstanding; answer only one.
let outcome = engine.resume_with(&graph, thread_id.clone(), vec![first_response]).await?;
// outcome is RunOutcome::AwaitingInput { parleys: [second_request], .. } -- still suspended.
let outcome = engine.resume_with(&graph, thread_id, vec![second_response]).await?;
// outcome is RunOutcome::Completed { .. } (or whatever the graph reaches next).
This partially-answered state is a property of the persisted Waypoint, not process memory: it
survives a full process restart and is queryable from a cold store handle. Responses are durably
consumed only when the first post-resume Waypoint actually persists — if the process dies between
validation and that write, the AwaitingInput Waypoint just read is still latest, and
re-submitting the identical responses is safe.
Expiry: on_expire Policies
A ParleyRequest optionally carries expires_at, evaluated lazily at resume time — there is
no background timer. Each request's own on_expire policy decides what happens once the clock has
passed it, independent of whether the caller's own submission even names the expired request:
OnExpire::FailRun(the default) —resume_withpersists aFailedWaypoint (its reason naming the expired parley and node) and returnsErr(EngineError::ParleyExpired). The thread is thereafter resumable only viareplay/forkfrom an earlier Waypoint — a plainresumeagainst it fails closed withEngineError::ThreadAlreadyFailed.OnExpire::ResumeWithDefault(value)—value(validated against the request's ownkindat graph-validate time for aGate, or at raise time for a directive-raised parley) is substituted as the response, withresponded_by: Noneanddefaulted: trueso an audit trail can see the substitution happened. The run then proceeds exactly as if that value had been submitted.
Chronicle: History, Replay and Fork
ChronicleService is a thin, port-only read facade (no paladin-battalion dependency) over one
thread's Waypoint history:
use std::sync::Arc;
use paladin::application::services::chronicle::ChronicleService;
let chronicle = ChronicleService::new(waypoint_port);
// Newest-first summaries, including every branch's lineage.
let page = chronicle.history(&thread_id, 20, None).await?;
// The full snapshot (Battlefield, vanguard, records, status) for one Waypoint.
let waypoint = chronicle.inspect(&thread_id, waypoint_id).await?;
// The newest summary on the branch rooted at `branch_root`, or `None`.
let latest = chronicle.latest_on_branch(&thread_id, branch_root).await?;
WarEngine::replay/WarEngine::fork re-enter the superstep loop from any past Waypoint, each
producing a new branch while the original chain stays untouched:
// Re-run forward from an earlier Waypoint, unchanged.
let outcome = engine.replay(&graph, &thread_id, from_waypoint_id).await?;
// Re-run forward, but first merge an edit into the starting Battlefield --
// the "what-if" primitive. An edit naming an undeclared field fails closed
// and persists nothing, exactly like a real node's delta would.
let outcome = engine.fork(&graph, &thread_id, from_waypoint_id, edit).await?;
Immutability is a hard, byte-for-byte invariant: every mainline Waypoint serialises to
identical bytes before and after a replay/fork, and calling either twice from the same
Waypoint produces two independent branches, disturbing neither each other nor the mainline. A
branch is a queryable attribute — Waypoint.fork_of/WaypointSummary.fork_of mark the branch
root and every subsequent Waypoint on that branch inherits the same value — so the whole branch
tree reconstructs from WaypointSummary alone, with no full-Waypoint loads.
Subgraph forks never share child Waypoints. A branch runs its NodeSpec::Battalion children
under a thread id derived from both the parent thread and the branch root
(ThreadId::child_on_branch), so a fork's subgraph child always starts fresh and
WaypointPort::latest on that child thread never resolves the mainline child's own history.
Graceful Shutdown from the Embedder's Side
WarEngine::with_shutdown_grace(Duration) (default 30 s) configures how long a mid-superstep
cancellation waits for the in-flight batch of node tasks before giving up on the stragglers:
use std::time::Duration;
use paladin_battalion::engine::WarEngine;
use paladin_battalion::engine::shutdown::ShutdownCoordinator;
let coordinator = ShutdownCoordinator::new();
let (token, _guard) = coordinator.register();
let engine = WarEngine::new(waypoint_port)
.with_cancellation_token(token)
.with_shutdown_grace(Duration::from_secs(30));
When the token fires while nodes are in flight, the engine keeps awaiting the WHOLE batch (never
just the one that triggered cancellation) until the grace deadline; nodes still running at the
deadline are aborted and recorded NodeOutcomeKind::Skipped { reason: "shutdown" }, their deltas
discarded. Those nodes' ids are re-listed in the Halted Waypoint's vanguard alongside the
normally computed next vanguard, so resume re-executes exactly them — exactly once — while every
node that finished inside the grace window merges normally. shutdown_grace = Duration::ZERO
aborts immediately.
An embedder that wants every in-flight run to drain on process shutdown constructs one
ShutdownCoordinator, registers every WarEngine run with it (register() returns a child token
plus an RAII RunGuard), and calls coordinator.cancel_and_wait(grace) from its own shutdown
path:
// On SIGTERM/SIGINT:
let outcome = coordinator.cancel_and_wait(Duration::from_secs(30)).await;
if outcome.drained() {
// every registered run finished inside the grace window
} else {
// the deadline elapsed first; any straggler is Skipped and re-listed for resume
}
paladin-server and ServiceRunner both wire this into SIGTERM/SIGINT already — see
Kubernetes deployment for the operator-facing
terminationGracePeriodSeconds/env-var contract. shutdown_grace_secs/graceful_shutdown are
runtime settings only: they are never hashed into the graph fingerprint and never affect
resume's GraphMismatch check.
The HTTP Surface
paladin-web exposes three thread routes behind the same authentication middleware
/v1/agents/* already uses; the one mutating route, POST /v1/threads/{id}/resume,
additionally requires an admin-role credential:
| Route | Behavior |
|---|---|
GET /v1/threads/{id}/state | The thread's latest status, plus outstanding parleys/responses when suspended |
POST /v1/threads/{id}/resume | Submits { "responses": [{ "parley_id", "value", "responded_by" }] }; returns 202 Accepted { thread_id, state_url, run_id } immediately; an authenticated caller without the admin role gets 403 |
GET /v1/threads/{id}/history | Paginated Chronicle history: ?limit=20&cursor=... (limit ≤ 100), { items, next_cursor } |
POST .../resume never holds the connection open: it validates synchronously (typed
400/404/409 errors on rejection, nothing persisted) and, only for a valid and complete
submission, either re-enqueues the same run_id onto the durable run server (Phase 27's Platform
API, when a run row exists for the thread) or, for a pre-run-server thread with no run row, spawns
the actual engine continuation as a background task registered with the process's
ShutdownCoordinator (run_id: null on the response in that fallback case) — either way, 202
returns immediately. A client polls GET .../state, or GET /v1/runs/{run_id} when run_id is
present, for the outcome. Two distinct 409 conflict codes share the same HTTP status:
thread_not_awaiting_input (the thread is not suspended) and graph_not_registered (the thread's
graph fingerprint has no WarGraph registered in this process). Every route answers 501 not_implemented, naming the config key to set, when no waypoint backend (APP_WAYPOINT_STORE_ BACKEND=sqlite|postgres) is wired.
The full run lifecycle — submission, cancellation, streaming, assistants, schedules and webhooks — is documented separately. This page covers the thread-level pause/resume/history surface; see Platform API — Runs, Threads, Assistants, Schedules, Webhooks for
POST /runs,GET /runs/{run_id}/stream, and everything built on top of the resume mechanism this page describes.
Never template a secret or credential into a Gate's payload.
GET /v1/threads/{id}/statereturns that payload verbatim to any authenticated caller. Apayload_templateis author-controlled, rendered from the Battlefield exactly like a prompt — treat it with the same care you would a log line or an error message, never a place to carry an API key or a database credential.
Interim authorization posture. The two read routes (
GET .../state,GET .../history) accept any authenticated caller, while answering an approval gate throughPOST .../resumerequires an admin-role credential and answers403otherwise. This narrows the phase's own D-24 decision for the mutating route only, applied because neitherThreadIdnorWaypointcarries an owner or principal to scope against yet (CR-01, commit00b1e552, rationale recorded inthread_controller.rs's module rustdoc). This is a documented, accepted interim posture, not an oversight: every credential configured for the resume route must be treated as admin-equivalent until Phase 27'sPLAT-06supplies real per-thread ownership scoping.
Notifying a Human a Parley Is Waiting
Notifying someone that a parley is waiting is application code composed from the existing
paladin-notifications port — this phase adds no new port for it. A typical composition, called
right after a RunOutcome::AwaitingInput comes back from start/resume_with:
use std::sync::Arc;
use paladin_core::platform::container::notification::{
Notification, NotificationChannel, NotificationContent, NotificationPriority,
NotificationRecipient,
};
use paladin_ports::output::notification_port::NotificationDeliveryPort;
async fn notify_parley_waiting(
notifications: Arc<dyn NotificationDeliveryPort>,
approver_email: &str,
prompt: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let notification = Notification::new(
NotificationRecipient::Email(approver_email.to_string()),
NotificationContent::new(
"Approval needed".to_string(),
prompt.to_string(),
"parley".to_string(),
),
NotificationChannel::Email,
NotificationPriority::High,
)?;
notifications.deliver_notification(notification).await?;
Ok(())
}
Call this from the same application code that calls WarEngine::start/resume_with — a
RunOutcome::AwaitingInput { parleys, .. } names exactly which requests are now waiting, so the
prompt and recipient can be derived from them directly. No engine or port change is required to
add a second channel (Slack, SMS, a webhook): construct a different NotificationDeliveryPort
adapter and call it the same way.