input template jq to decide what is due, and update template jq write the ledger after
each step. The pattern is client-agnostic: a client declares only its intents, phases,
follow-ons, and reply parts — the manifest — as the attachment’s declarations.
The agenda ships with babelfish as resource files under
resources/state-template/agenda/ — the one state-template document and its template jq
bodies — not as engine code and not as a flow. It is installed like any other state
template (tai state-templates put, then attach it to a state). Babelfish is proprietary
and licensed commercially; the agenda ships with it, not on PyPI.What the template stores
The template’s schema fragment holds four keyed lists and three single slots. The lists are under thecomposing writer regime — many keyed writers per list — while turn_talk,
last_outbound, and transcript are each single, one whole-object writer:
ledger— one row per intent:id,kind, an optionalitem(the intent’s item identity when the kind declares one intent per item;nullfor an itemless kind),source(inbound|event|tick|standing|follow_on— a record derivestickwhen its event turn’s inbound source isevent:tick, the event kind a schedule adapter posts through the event door), the classifier’s extractedfields, arank(the order among open intents of a sequence group), aminted_run{run, pass}, and aclosed{resolution, sent_run}mark that compose writes. A closed intent is pruned at the next mint once no open intent still names one of its phases (below).ledger_phases— one upserted row per phase key<intent id>:<item>:<phase>, carrying theoutcomelabel the flow returned, its derived class (resolution— settled:done, or one of the manifest’s declared not-done names) or wait (wait_for—whoa party, orwaits_onanother phase key whenwhoisphase), the outcomefields, the writingrun/pass, and asentrun stamp compose sets.ledger_inbound— one row per binding of an inbound span to a phase;relationrecords HOW it bound (answers,ambiguous,event,yes_no,form,option,reactivate,follow_on), and an ambiguous binding carries anattemptcounter (the clarify round, from 1).turn_talk— the classifier’s free text for THIS turn ({run, text}), an input to the compose talk slot.last_outbound— a single{run}slot compose sets to the turn id whenever it emits any outbound content (a part or the turn’s talk); a fully silent turn (no part and no talk) re-emits the prior pointer rather than erasing it. It names the last run whose reply emitted anything; a bare yes/no binds only to a wait whose ownsentstamp equals that run.transcript—{lines: [{role, text, run}]}, the composed-talk window. Compose is the sole writer — the platform exposes no thread read to plugins — appending auserinbound line and, when it speaks, anassistantoutbound line each turn, truncated to the talk slot’stranscript_window_lines. The talk slot reads the PRIOR window as context.
Two ids named run
A ledger and ledger_phases row each carry a field called run, and the trace stamp
carries another — they mean different things, and the page states the contrast so a dump
reads right:
- A row’s own
run(andminted_run.run) is the turn id — the unit of work, the turn the row was written for. _trace.runis the writing run’s session id — the client flow run in which the update template jq wrote the row.
The trace
trace.enabled is true, so every row carries a _trace object:
{meta, run, turn, inbound, at} — the writer-supplied meta, the run’s session id, the
turn id, the turn’s inbound id, and the write time. The engine supplies meta: {node: <node id>}, so the writing node reads at _trace.meta.node. Because a composing list admits
several keyed writers per row kind and nothing statically pins one node per field, the trace
stamp is the evidence a dump needs for which node wrote which row.
Record ids are <turn.inbound.id>/<kind>/<n>, where n is the 0-based mint ordinal within
the run across every source (inbound spans, standing conditions, follow-ons) — one counter
takes the next ordinal for every row it mints, so n is unique per turn. Provenance lives in
source and event, never in the id. Both the mint and the record update derive source
through the template’s shared turn_source input jq — tick when the inbound source is
event:tick, event for any other event turn, else inbound.
The manifest a client declares
The attachment’sdeclarations carry intents, phases, outcomes, and parties — the
wait parties beyond the engine’s own — plus clarify_kind, unmatched_kind, clarify_limit
and talk_slot. The schema admits no other key. A placeholder manifest — kinds status and
utilities, the synthetic vocabulary this page uses throughout:
Intents
An intent declares itskind, classifier description, an optional sequence group, its
sources, a standing predicate, and EITHER items (a fixed list; empty means the single
implicit item _) OR item {field} (one intent per item, the item identity read from a
classifier-extracted field — exclusive with items). A kind may also declare
fields {<name>: <JSON-Schema>}, the per-kind extraction schema merged into the classifier’s
forced per-span object beside the mandatory text; an item.field, and every rank.match
key targeting the kind, must name one of those fields. A kind with item {field} is
single-item in the phase sense (its item segment is _); the item identity lives on the
ledger row, not in the phase key.
An intent’s standing is a jq predicate string that decides whether a standing intent
of the kind should be open. Because it reads a sibling state — a value on another state
the client also binds, such as an opt-in flag — it lives in the client’s flow binding over
that state, not in the agenda template (a template-scoped input jq sees only its own
attached subtree). The client’s mint step evaluates it each pass and opens one intent of the
kind when it holds and none of the kind is already open (a null predicate contributes no
intent).
An intent’s sources lists the record sources the kind admits — inbound a classifier
span, event/tick an event-door turn. When non-empty the record update ENFORCES it: a span
minted for the kind from a source the list excludes raises
agenda-record: kind <kind> does not admit source <source> (a standing or follow_on
record is exempt — those are not inbound sources).
Phases, labels, and follow-ons
A phase names itskey, kind, item, a predecessor, its ordering_waits, an
optional expected_input, and outcomes — the outcome labels
the phase can record. A phase names no handler — the flow runs its handler for the phase and
returns the outcome. A predecessor is a phase key (satisfied when that phase resolves
done), "record" (the intent’s item order — satisfied once the earlier items’ same phase
is settled in any way), a {phase, outcomes} object (satisfied when the predecessor phase’s
current outcome is one of the named labels), or null (a first phase). ordering_waits is a
list of {kind, phase} cross-intent gates, phase "*" meaning every phase of the kind. An
expected_input declares its kind (yes_no | text | option | form | event) and
follow_ons keyed by the answer value. An event input is {kind: "event", event, key, via?}: event is the event kind that correlates here, key the payload key whose value
names the item, and an optional via a jq program over the flow root (with $key bound to
the payload’s key value). For an item kind (one-per-item, or a static-item phase) via
must yield the wait’s item — the ledger row’s item (item_of) for a one-per-item kind, else
the phase key’s item segment — and the event binds the wait naming it; a via yielding
nothing binds no item wait, and an absent via matches the key value itself. For an
itemless kind (item segment _) a via yielding null — which a wait that declares no
via also yields — means this event is ours and binds the kind’s single open expectation,
while a via yielding the empty no-value (jq empty) means not ours and leaves the event
unmatched. An event whose key matches zero or several waits binds nothing and mints one
unmatched_kind record; a payload missing key raises agenda: event <kind> carries no '<key>'.
Each label is {outcome, resolution? | wait_for {who}, fields, follow_on?}, declaring
exactly one of resolution (a class) or wait_for (a party). A resolution is one of two
classes: done, the class the engine names, whose successors are reached and whose chain
continues; or a not-done resolution, which stops the chain — the intent runs out of open
phases, closes, and compose speaks that resolution. The engine names no not-done class of its
own: every not-done name is declared by the deployment in
parameters.resolutions and referenced by name from a label. A wait_for
names the party holding the wait: user and phase are the engine’s own (who: "phase"
waits on another phase named at run time by the handler’s waits_on), and every other party
is declared in the manifest’s parties list and referenced there by name. fields are the
fields the label carries; an optional follow_on fires when the label is recorded. A
follow-on is exactly one action:
item_field on a mint binds the open (kind, item) intent’s waiting phase (else
its next reached-unsettled phase, else mints a fresh record carrying that item); an
item_field on a close settles the open intent of that (kind, item).
A mint’s item_field may carry a list: the mint then fans out, applying that same
match-else-mint rule once PER ELEMENT in the same pass — one record (or bind) per element, each
carrying its element as item and starting its own pass count. An empty list places nothing;
an absent value still raises. A fan-out targets a kind that declares item {field} (one intent
per item), so each record carries a single element — a list into any other kind raises at
attach, as does a list on a close, which names one item.
A follow-on rides an outcome label’s outcomes[].follow_on, and a label’s readable fields
ride outcomes[].fields.
Parts rendered from fields
An outcome maps a(kind, phase, outcome) to a reply part. Every outcome row
carries its kind: a phase key can repeat across kinds, so (kind, key) is the phase
identity everywhere — in the outcome map, the expectations items, and the attach check. A
part is rendered from the row’s fields, never a literal payload:
A part may also declare
repeat_over (a list field — compose emits ONE part per element, the
element bound as item in the render context) and to (user, the default, or a
party the manifest declares in parties — a part sent to a declared party leaves compose as
a deliveries entry, never in the reply to the user). fields lists the fields the part
reads; every field the part names —
fields, field, schema_field, values_field, repeat_over, a links target, a params
value, url_field — must be a member of the label’s fields. A form’s values_field prefill
is forwarded as the platform’s per-send form data.values; compose refuses loudly (naming the
part and the key) a key that is not a top-level property of the form schema, or a value that
does not satisfy that property’s schema — never a silent partial prefill. A client seeds each part.text path
through the platform’s template-upload door — POST /api/upload-template with {path, content} writes the content-store path the part names — the surface the
stored-content guide documents.
The composed-talk slot
talk_slot (optional; null = off) turns on the composed-talk slot:
{prompt_ref: <path>, transcript_window_lines}, where prompt_ref is one content-store talk
prompt template id and transcript_window_lines (default 10) bounds
the transcript window. When declared, compose runs the talk step after the
fixed parts render, handing it the talk facts, the fixed parts, the prior transcript window,
the inbound text, the prompt, and the classifier’s turn_talk text; it speaks ONE verified
reply appended last (see the compose step). A talk-disposition part with no
talk_slot declared is refused at attach.
The rules the attach check enforces
The template’sdeclarations.check is the agenda’s structural rule; it runs at attach, names
the first offender, and the attach refuses on any message it returns. In an action message
<via> is follow_on.<label> (a label’s follow-on) or answer.<key> (an
expected_input.follow_ons entry).
The
close rule spans every phase of the target kind because the target phase is chosen at
run time, so the outcome map must cover the resolution for all of them. The manifest declares
no data-fact preconditions, no discard rules, and no thresholds — dueness is computed
from the ledger, never gated on stored values.
The same attach seam also compiles each jq the manifest declares against the template’s
schema — a phase’s via correlation program — and refuses one that does not compile or whose
value type contradicts what the field expects. The cross-state standing predicate is NOT
checked here: it reads another state and lives in the client’s binding, so the client’s flow
validates it against that state.
Re-attaching over open intents
An attach that replaces the manifest while intents are still open is checked against those open records too. When an open intent names a kind, a phase, an outcome label, or a part the NEW manifest no longer declares, the attach is REFUSED with422 and the refusal lists every
orphaned record — the declarations never change under a conversation whose next turn could no
longer resolve:
options.orphans = "close" and
names a declared not-done resolution in options.resolution (one of the new manifest’s
resolution values other than done; a name the new declarations do not declare is itself
refused). The attach then closes every intent the new manifest no longer covers with that
resolution instead of refusing — set on the intent’s open phase rows and marked closed on
its ledger row. The close carries no sent_run, so it is unspoken: the next mint’s prune drops
the intent and compose says nothing for it.
The parameters
Two attach parameters tune the template without editing its document:resolutions— the enum schema substituted into theresolutionfield at the{"$parameter": "resolutions"}marker, default{"enum": ["done", null]}.doneis the only resolution the engine names; every OTHER member of the enum is a not-done name the deployment declares here, and a label referencing one closes its intent instead of reaching the phase’s successors. The template jq read the allowed set as$parameters.resolutions.enum; a deployment names its own classes (deferred,expired,declined) and keeps the wording neutral, so none inherits a business vocabulary.yes_no_words— the bare yes/no word list the mint binds without the classifier, default{"yes": ["yes", "y"], "no": ["no", "n"]}, read as$parameters.yes_no_words.
The template jq
The agenda’stemplate_jq split into two purposes. The input programs are read-only
predicates over the record subtree — the client’s flow reads them through the
template-jq prelude (tjq_<name>({…})) or the record
GET …/template-jq/{name} route to decide what is due each pass. . is the attached record
subtree; $declarations is the manifest, $parameters the attach parameters, and a program’s
declared params ride the single object $params. The core predicates read verbatim:
The rest of the 56 input programs:
answer_target, answered, anything_due, bound,
clarify_rows, clarify_step, close_targets, closed, complete, compose,
current_outcome, deadlocked, done_outcome, due, due_set, expectations,
fill_target, follow_on_instances, group_of, has_open_phase, intent_has_inbound,
intent_row, intent_user_wait, intent_wordless, items_of, key_parts,
manifest_phase, open_clarify, open_intents, open_option_expectations,
open_yes_no_expectation, outcome_after_binding, pending_follow_ons, placed,
rank_targets, reactivated, reply_safe, resolve, settled, settled_this_run,
turn_source, unresolved_class, via, waiting, wordless_conflict, and the graph
helpers the attach check reads to refuse cycles (mint_adjacency, ow_adjacency,
seq_edges, reachable_cyclic).
due_set orders the due items by intent rank, ties by the total mint order, one grouped
intent per pass; expectations returns the open waits [{key, kind, phase, outcome, who, expected_input, fields, question_ref, schema_field, schema, options}], the expected_input
joined from the manifest phase (the wait row carries only wait_for and fields), the
schema_field/options read from the outcome map’s part — so the mint binds a form re-entry
by the row’s own schema (fields[schema_field].required) and an option tap by the row’s own
candidates, never by a static phase schema. pending_follow_ons reads the same placed
predicate the seed writes against, so the pair never disagrees — a branch never fires over an
empty slice, and the loop never over-runs a placed follow-on.
complete asserts every reached phase has settled or is waiting, and it holds wherever the
loop stops, because the attach check keeps each kind’s ordering_waits, predecessor, and
mint graphs acyclic. At loop end nothing is pending, or the loop would not have stopped; a
reached phase that is neither settled nor waiting would be pending but ordering-blocked, so
its blocker is a pending same-kind phase, whose blocker is another pending phase, and so on —
a chain that, over an acyclic wait graph, must end at a pending, ordering-satisfied phase,
which is due. That contradicts the loop having stopped, so complete failing is a real bug —
a handler that fired without recording an outcome — never a benign ordering stall.
The stale disjunct re-opens settled phases, and it terminates for the same reason: a stale
phase stops being stale once it re-records at a pass at or after its predecessor’s, each
re-record stamps a pass no earlier than the predecessor’s, and the predecessor graph is a DAG
(the attach check refuses a cycle), so the re-reach cascade runs down finitely many edges. A
settled phase whose predecessor was re-settled this run is therefore due, not complete’s
business: complete stays true over it while the loop keeps going, exactly as it does over a
reactivated open wait.
The update programs
Theupdate template jq write the ledger. Each takes {record, input} — .record the
current subtree, .input the value the binding adapter
shaped from the node’s output — and returns a template-relative op batch under the writer
regimes; the record POST …/template-jq/{name} route (or a node binding’s update) applies it.
The five are:
mint— turns each inbound into records or binds it to an open expectation WITHOUT language judgment (writesledger,ledger_phases,ledger_inbound). It mints standing intents whose predicate holds, binds aneventturn byexpected_input.eventand itsviato the waiting phase whose item matches, binds aformsubmission by its own row schema, binds a tappedoptionby its own row candidates, and binds a bare yes/no (viayes_no_words, no classifier call) when exactly oneopen_yes_no_expectationis open. An event with zero or several hits, or a form with no hit, mints anunmatched_kindrecord; a form with several hits opens a clarify. It also prunes intents already closed and no longer referenced, and computes the open expectations the classifier reads.record— takes the classifier’s structuredverdict(or null) and writes per-kind spans asledgerrecords (sourcefromturn_source,itemfrom the kind’sitem {field}when declared),answersbindings asledger_inboundrows, and eachambiguousspan as a clarify — re-binding an already-open clarify and climbing itsattemptup toclarify_limit, at which point it records theunresolvedlabel (writesledger,ledger_phases,ledger_inbound,turn_talk). With atalk_slotdeclared and a non-blankverdict.talk, it is the sole writer ofturn_talk. Before it writes a row it refuses two bindings that resolve to one phase key in one pass (agenda: two bindings resolve to phase <key> in one pass; a phase is answered once per pass, never twice) and an ambiguous binding naming fewer than two candidates (agenda: ambiguous binding on <span> names <n> candidate(s); ambiguous needs two or more— the classifier contract, not the template, guarantees an ambiguity has options to choose between).seed— runs every pass; re-evaluates standing conditions and places pending follow-ons by one match-else-mint rule (bind to the open intent’s waiting phase, else its next reached-unsettled phase, else mint a new record — never a closed intent) (writesledger,ledger_phases,ledger_inbound). Follow-ons come from a wait’s answer and from each label (resolution OR wait) recorded this run, read aslabel.follow_on. Aclosewrites the target’s open phase a full outcome row visible toduein the SAME pass; arankis amerge_by_keyon the targetledgerrow. Every action is bounded by the sharedplacedpredicate — one placement per instance — and, before it writes a row, seed refuses two follow-ons that resolve to one phase key or to one intent id in a pass (agenda: two follow-ons resolve to phase <key> in one pass; …/agenda: two follow-ons resolve to intent <id> in one pass; …).outcome— validates each phase’s returned{key, outcome, fields, waits_on?}(thekeyis due this pass; theoutcomenames a label the fired phase declares; everyfieldskey is one the label lists; aresolutionlabel’s derived class is among the attachedresolutions; await_forlabel’s derived party isuser,phase, or one of the manifest’s declaredparties; awho: "phase"label requireswaits_on) and writes oneledger_phasesrow per outcome — the class and party read from the declaration, never the handler (writesledger_phases). Two results carrying one key are refused before a row is written (agenda: two results resolve to phase <key> in one pass; …). A breach raises loudly.compose_mark— after the pass loop, writes theclosedmark on every fully-settled intent, thesentrun stamp on each rendered row,last_outbound{run}when the turn speaks, and thetranscriptwindow (writesledger,ledger_phases,transcript,last_outbound). See the compose step.
reconcile is the template’s own three-program set (orphans, close, resolutions) the
platform runs when a re-attach retires declarations over
open records.
The compose step
Compose runs after the pass loop, reading the rows the outcome update committed. Thecompose
input program first asserts complete($run), else the flow raises
agenda: incomplete run: <intents> (the open intents’ ids) — which becomes the route’s error
reply. Then, for each row of settled_this_run and each open wait whose question has not yet
been sent, it
resolves the outcome map’s part — keyed (kind, phase, outcome) — and renders it from the
row’s fields:
textrenders throughrender_textover the part’s content-store template id, the row’sfieldsas the render context, and the platform’s renderer resolves the locale from the subject — the template selects no language variant. Atextthat is not a template id string raisesagenda: part text for phase <key> is not a template idrather than sending an empty message.options,form,template,mediabuild their surfaces from the same fields —optionsfrom a list field or the fixed values,formfromfields[schema_field],template/mediafrom their named fields. Arepeat_overpart fans out to one part per element of the named list field, the element bound asitem.talkhands its fields to the talk slot as a fact and speaks no part;carriedmerges its fields into the named same-intent carrier;nonespeaks nothing but is still stampedsent. A carried row whose carrier is not spoken this turn raisesagenda: carried row <label> has no carrier this run— unless the row is an open wait and the carrier’s current outcome is the label the part names and already carries asentstamp, in which case the question stands as already asked: the row sends nothing, renders no unit, and is stampedsentthis run. (A reactivation re-records an open wait and drops itssentstamp, so compose meets the wait again while its carrier stays unsent.)- a
tonaming a declared party routes the part intodeliveries— a list of{to, part}the client’s ownsendnodes deliver — instead of the reply to the user.
fields names a field the row does not carry raises
agenda: part <kind>.<phase> <outcome> needs field '<f>' the row does not carry, and an
outcome with no matching map row raises agenda: no part for <kind>.<phase> <outcome>
before any closed mark, so an unmapped outcome never silently closes its intent.
The talk slot. When the template declares a talk_slot and either a talk fact was handed
or the classifier supplied turn_talk text, the talk step runs after the fixed
parts render, and its one verified reply appends last. The classifier’s talk is an INPUT
to the slot, never spoken as a part directly, and when the slot is declared but no fact and no
classifier talk exists, no talk part is emitted. The slot’s check enforces two fatal
rules and one non-fatal one:
- every handed fact leaf must appear in the text, else
agenda-talk: fact <label>.<field> not spoken(fatal); - every id-shaped token (
[A-Z][A-Z0-9]*-[0-9]+, e.g.A-42) must trace to a handed source — a fact, a fixed part, a transcript line, or the inbound — elseagenda-talk: text carries '<tok>', which was not handed(fatal); - every bare numeric token not found in those sources is NON-FATAL — collected on
unverified_numbers(visible in the run record’s node output) so ordinary prose numbers (“two options”, “in 5 minutes”) never fail a turn.
compose returns {parts, deliveries} — parts the ordered AnswerPart[] the route returns
as its reply, deliveries the other-party parts. The compose_mark update then writes the
closed mark on every fully-settled intent, the sent run stamp on each rendered row (the
guard that sends a wait’s question exactly once, never re-asking an open wait whose question
already went out), last_outbound {run} whenever this turn speaks (a silent turn re-emits
the prior pointer), and the transcript window (written after the talk, so the stored outbound
line includes the talk text).
Using the agenda from a flow
A client attaches the agenda template to a state and drives it from one flow, wiring the pieces the template does not itself run: a classifier, the pass loop, and the reply. There is no shipped node, no lowering, and no chips — the template is data, and the flow orchestrates it through per-node bindings and the template-jq prelude:- mint — the flow applies the
mintupdate at the start of the turn (input injections read the openexpectationsandturn_source), turning the inbound into records or binding it to an open expectation. - classify — the flow’s own classifier node (a
tools_agent) runs once over the inbound when the mint left spans to classify; its forced response schema and prompt are built by jq from the manifest’s intents. The flow applies therecordupdate over the verdict. - the pass loop — a Router in
Loop mode runs the passes: each pass applies the
seedupdate, readsdue_set/anything_due(the loop’s stop condition) through the prelude, dispatches each due item to the flow’s handler for that intent — one run per due item, an unsequenced kind in parallel, a sequenced kind one at a time in rank order — and applies theoutcomeupdate over each handler’s{key, outcome, fields, waits_on?}. - compose — once the loop stops, the flow reads the
composeinput program for the reply and applies thecompose_markupdate; the route returns.partsand feedsdeliveriesto its ownsendnodes.
declarations there — its manifest.
A handler for an intent reads the due item — its {key, intent, item, kind, phase, rank, fields} — from the pass, does its work through the flow’s own tools or a published flow, and
returns {key, outcome, fields, waits_on?} where outcome is a label the fired phase declares
and waits_on is supplied only for a who: "phase" wait. The handler reads state only through
the flow’s own bindings and never writes the agenda ledger directly — the flow’s outcome
update records the returned label, deriving the class and party from the declaration.
The pass-loop rules
- No asks inside the pass loop. A handler never contains an
askor any interactive preset: a parking ask inside a fan-out re-runs its siblings on resume. An outbound question is await_forlabel the handler returns, spoken by compose — never a mid-loop prompt. - No preconditions on data facts. Dueness comes only from the phase graph and the ledger’s outcomes and bindings; the manifest declares no thresholds or stored-value gates.
- A hit cap is a bug. The loop’s max-runs cap (default 20) is the backstop, not a
conversation state: reaching it raises, and the failure travels the flow’s ordinary
on_errorpath. The template computes no static pass bound and the manifest declares none; what makes the loop finite is the attach check’s refusal of a cycle in thepredecessor,ordering_waitsand mint graphs, plus the rule that a fired phase records an outcome and so stops being due. The cap on the client’s loop must be generous enough for the manifest’s longest predecessor/ordering-wait chain, which the client sizes from its own declarations.
How re-entry binds
No outbound part carries a correlation key (AnswerPart forbids extras), so a later inbound
binds to an open wait by MATCH, and the mint update does it without the classifier:
- an event binds to the waiting phase whose
expected_input.eventnames the event’s kind and whosevia(a jq program compiled over the flow root,$keybound to the payload’skeyfield; absent means thekeyvalue itself) resolves to the wait’s item — the ledger row’s item for a one-per-item kind (item_of), else the phase key’s item segment, else the intent id. The event door delivers it as a turn, and a payload missing thatkeyraisesagenda: event <kind> carries no '<key>'. An event with zero or several matching waits binds nothing and instead mints oneunmatched_kindrecord; a malformed event (noid/kind) raisesagenda: malformed event turn — an event carries an id and a kind. - a form submission binds to the open form-wait whose ROW-carried schema’s required
properties are all present; several matches open a
clarify_kindintent, no match mints anunmatched_kindrecord carrying the form payload; - a tapped option arrives as the option’s value in the message and binds to the single open option-wait whose ROW candidates hold that value;
- a bare yes/no binds when exactly one yes/no wait was asked in the last outbound line
— the mint matches it against
open_yes_no_expectation, so a yes/no never lands on a question two turns old.
unmatched_kind (default unmatched) —
{kind, source, event: <payload>, fields: {reason, candidates, inbound_kind}, rank, minted_run}, reason no_match (zero) or ambiguous (several), candidates the hit phase
keys. It reaches its own phase and is resolved by that kind’s handler (a done or a declared
not-done resolution) like any other intent, and compose speaks its part. The record bypasses
the record update’s sources gate, so the check enforces the unmatched kind’s admission statically (its sources
must be absent/empty or cover both event and inbound); the mint never consults sources
at run time.
Wait parties. A wait’s who is user, phase, or a party the manifest declares in its
parties list and the label names. user and phase are the engine’s own. The classifier
binds a span only to a user wait — the platform-owned expectations instruction tells the
model so, and every expectation item carries its who. A declared party is held for someone
off this conversation: its wait wakes on an event of its declared kind delivered through the
event door (whatever posts that event is a
deployment-side adapter). A phase wait resolves internally when the phase its handler named
in waits_on records a resolution.
Termination
The loop always terminates.due excludes a phase with an outcome after its latest binding
this run, and every fired handler records an outcome (the compose assertion catches one that
did not), so a phase never re-fires. A phase-predecessor chain ends the moment a phase
resolves to anything other than done: a not-done resolution leaves every phase successor
unreached, so the intent runs out of open phases and compose closes it, speaking that
terminal resolution. Item order under a record predecessor is the exception — it advances on
any resolution of the earlier items, so a not-done resolution on one item never drops the
items that follow.
Cross-intent waits are a DAG over (kind, phase): the attach check refuses a cycle in the
ordering_waits graph, the predecessor graph within a kind, and the mint graph, so a chain
of waits always bottoms out in a due phase and a fresh intent can never be minted every pass.
Re-activations, closes, and ranks are each bounded by the placed predicate — one placement
per instance, an empty target set counting as placed — so a settled-first target never keeps
the loop alive. A closed intent is never re-opened, and it is pruned once no open intent
still names one of its phases. The pass cap is the backstop, and hitting it is a manifest bug.
