turnframe-runtime
The turn runtime of Turnframe: the code that decides what a user's turn actually does, and then does it.
turnframe-core defines the contract: what an understanding is,
what a command needs before it may run, what a card means. This crate makes the
decisions and carries them out. The shape of it is one sentence: the model
proposes meaning, deterministic code decides effects, committed events decide
claims.
One turn, in stages
Orchestrator::handle_turn runs the pipeline of spec §23. Each stage is its own
module, so a trace, a phase marker and a replay record can all point at the same
place.
| Module | What it decides |
|---|---|
config |
how much autonomy the model gets (§11.1), which risk classes a sandbox refuses outright (§11.4), and the conservative defaults of Appendix A |
budget |
what a sandboxed turn may spend (model calls, prompt tokens, wall clock) and which bound stopped it (§11.1) |
understand |
the cases in view as the understanding tasks see them (opaque tokens, labels, what each still needs) and the domain's own dry-run check of every act |
resolve |
which case "the Ferri trip" is, or that it is a question. Recency, list order and plausibility never break a tie (I8) |
policy |
whether a command may run now, and if not, which card would authorize it (§14.3, I12) |
reduce |
the whole-turn algorithm of §13, giving every act an explicit result (I11) |
interactions |
durable cards: written before any sentence refers to them, one blocking card per case, resolution by compare-and-set, Resolved only once the command commits (§15) |
resume |
what a card carries, so answering it runs the acts that waited for it (§13.3) |
execute |
admission to the journal before any effect, optimistic concurrency, typed outcomes, the outbox, and one all-or-nothing commit (§16) |
compose |
receipts from committed events, answers with an explicit state basis, and the outcome the reply is written from (§17, §18, §19) |
narrate |
the reply's model tasks: the acknowledgement and its review, one answer per question, and step prose when asked for |
stream |
nothing that states an outcome goes on the wire before the commit; the reply is published whole (§18.5) |
trace |
a turn from message to reply, for local debugging |
recover |
after a crash: restart, resume by idempotency key, regenerate the answer, or reconcile (§23.1) |
dispatch |
the other half of the external-effect saga: claim a due outbox row, send it, settle it, driven by the application's own task and never a thread this library starts (§16.4) |
orchestrator |
the facade that runs them in order, writes the phase marker at each step and records a replay record for every turn |
The properties worth stating out loud
- A guess is never a target. When two authorized cases match a mention, the
resolver returns
Ambiguousand the reducer raises a selection card. There is no code path inresolvethat reads a timestamp, a list position or a model confidence. - A click is not a blanket consent. Answering "which trip did you mean?"
authorizes nothing.
policymaps eachConfirmationPolicyto the specific card whose answer satisfies it, andHumanProfessionalReviewmaps to no card at all, because a card the user can click would let them approve themselves. - A click costs nothing. A turn carrying only a card answer reaches execution with no model call at all: the meaning of the click is the stored option, and the runtime reads it from there.
- A clarification interrupts; it does not cancel. The card carries the act it was guarding, so picking one of two Ferri trips applies the change that was waiting to that trip, and answering "no" to a conditional instruction records that it was declined. Nobody restates a request because the server asked a question about it.
- Silence is not permission. A command the domain never classified is
treated as
CommandPolicy::conservative: irreversible, explicit click. - A correction inside the turn lands before any effect. "Change it, actually leave it" compiles nothing, rather than writing and then undoing.
- Model output is all or nothing. A task's answer that breaks its schema or a check is sent back whole with the exact error, within its repairs. A unit still not understood becomes a notice, and every act on its record is held.
- Admission precedes effect. Every command is journaled under its idempotency key before the domain hears about it, so a crash is resumed rather than repeated, and resuming means handing the executor the journaled entry, not re-planning the turn against a state that has since moved.
- Uncertainty is a state. A timeout after transmission becomes an unknown outcome carrying an attempt identifier. Nothing in this crate retries it.
- Events authorize claims. A receipt is rendered from committed events by the workflow itself; a command that failed contributes none, so there is nothing to render a success from. The reply's writer is handed only the turn's outcome and reviewed against it, and the assembled turn goes through a structural check that reads the record rather than the prose. No word matching runs on what a model wrote: a substring match cannot see a negation, and would refuse the true sentence on exactly the turns whose only true sentence is a denial.
- A budget is enforced, not advertised. A sandboxed autonomous turn that spends its model calls, its prompt tokens or its wall clock stops, and the error names the bound that stopped it. Before the commit that means the turn fails with nothing written; after it, the effects stand and only the wording is cut short.
- Every question gets its own answer. Each is a small task run beside the others, and a question no model answers still gets a block, reported as not written.
- Effort buys judgment, never authority. A turn runs at
low,mediumorhigh(OrchestratorConfig::effort, orTurnInput::effortfor one turn).highvotes, reasons and checks the whole reading;lowskips the reply review. Policy, cards and the claim guard read no level (ADR-020).
Example
Configure the runtime and check that a sandbox refuses what §11.4 says it must.
use *;
use ;
let config = conservative;
config.validate?;
assert_eq!;
let sandbox = sandboxed_autonomous;
assert!;
assert!;
# Ok::
Building the target catalog is the step that decides the model will never see a record identifier: it gets opaque tokens and server-authored labels, and the mapping back stays here.
use *;
use ;
// One resolver per turn, over the two trips this actor may address.
let resolver = builder
.candidate
.candidate
.build;
let catalog = resolver.catalog; // what understanding sees: tokens and labels, no row ids
assert_eq!;
assert!;
assert!;
Wiring a whole orchestrator
let orchestrator = builder
.workflows // Arc<WorkflowRegistry>
.providers // Arc<ProviderPool>
.stores // turnframe_store::stores::Stores
.case_directory // which cases this actor may address
.knowledge // optional, for answers (§19.2)
.policy
.observer
.build?;
let answer = orchestrator.handle_turn.await?;
The runnable version lives in the integration tests: tests/support/mod.rs
assembles the sample trip and traveler domains from
turnframe-test against its in-memory stores and a scripted
provider, and the twenty scenarios of spec §27.4 are one named test each in
tests/runtime_scenarios.rs. tests/chaos.rs kills a turn at each of the
seven crash boundaries of §27.7 and asserts idempotent recovery and truthful
status. tests/continuation.rs answers a clarification and checks the work it
interrupted actually happens, tests/budget.rs spends each bound of a
sandboxed budget in turn, and tests/answers.rs puts three questions in one
turn and checks each gets its own answer, in the order asked.
What a turn reports
The Observer handed to .observer(...) receives every signal of spec §26.2
and §28, so each panel of the reliability dashboard has a series behind it. The
runtime pushes that same observer into the understanding, composition and
interaction stages when it builds them, which is why an application that
supplies its own Composer still reports on the same series.
How often each one fires matters when you write the alert:
| Signal | Once per |
|---|---|
turn.received, turn.completed, turn.failed, turn.duration_ms |
turn; the duration whether it succeeded or not |
task.completed, task.repaired, task.escalated, task.vote_disagreement |
model task, repair round, escalation and split vote, for understanding and the reply |
budget.exhausted |
bound a turn's model calls reached |
case.not_authorized |
candidate the case directory refused |
projection.duration_us |
case projected, so several times in one turn |
reduction.duration_us, persistence.duration_ms |
turn |
interaction.resolved, interaction.failed |
card settled, on whichever path settled it |
provider.latency_ms, provider.fallback |
provider attempt, the abandoned ones included |
narration.latency_ms |
acknowledge, answer or review call |
provider.capability_mismatch |
candidate routing refused for a capability it lacks, visible only when routing then found nobody, because that is when the router hands its rejection list back |
external.latency_ms, external.reconciled |
outbox row sent, and unknown outcome settled |
tests/signals.rs drives each of them through the real
pipeline and asserts the labels, and its last test fails if a declared signal
has no driver at all.
Streaming a turn
stream_turn runs the turn on a task and yields TurnEvents. Phases go out as the
turn progresses, and each understanding step as it is decided, so a surface can show
what the message was read to say while the rest runs. Blocks follow once the commit
has landed; the reply is reviewed before it is shown, so it arrives whole.
let mut events = new.stream_turn;
while let Some = events.next.await
Dispatching the outbox
A command with an ExternalSaga scope leaves an outbox row inside the turn's
one atomic write. OutboxDispatcher is the reference worker that picks those
rows up, and it is driven by your task, because a library that starts a
thread of its own would keep calling external systems out of a process that was
only supposed to answer a turn.
let dispatcher = new
// It runs on your task, not the orchestrator's, so it is given its own
// observer: without one, `external.latency_ms` and `external.reconciled` stay
// empty and the remote half of the saga is invisible.
.with_observer;
// Your loop, your shutdown, your schedule.
loop
A sender classifies its own outcome, and the classification is the contract: a
send that timed out is Dispatched::Unknown, never a retry, and the row waits
for OutboxDispatcher::reconcile to settle it against the remote system.
Running beside the path you are migrating from
An application moving off a free tool-calling agent cannot cut over on faith: it
has to run both paths on the same turns and compare them while the old path
stays authoritative. Orchestrator::plan_turn runs the whole decision pipeline
(cases loaded and projected, the message understood, targets resolved, turn
reduced, policy applied) and returns a PlannedTurn before the first side
effect of any kind. No card is persisted, no command journaled, no event
appended, no outbox row written, no conversation block stored.
That is enforced by the types rather than by care: the planner holds the read
half of the stores (ReadOnlyStores) and the loading half of each executor
(ErasedCaseLoader), so commit, insert and execute cannot be named from
it at all.
let planned = orchestrator.plan_turn.await?;
planned.would_persist; // the cards it would have written, not written
planned.would_execute; // the commands it would have run, not journaled
planned.would_claim; // what the answer could have said
// Same turn, state handed in: a recorded corpus becomes a shadow corpus.
let planned = orchestrator
.plan_turn_from
.await?;
// And a shared name for every way the two paths disagreed.
let report = compare;
report.any_against_shadow; // a refusal on an ambiguous target is not one
divergence carries one asymmetry deliberately: when this library
refuses a mutation because it could not tell which record was meant and the old
path performed it anyway, the finding is against the old path. There is no
traffic router, cohort selection or kill switch here: those depend on how an
application identifies conversations and belong in the application.
Links
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.