aspace://workflow/start

Building AgentSpaces workflows

Workflow guide

This guide walks one business workflow, an insurance claims flow, from the first claim to the last ledger line, and shows how AgentSpaces expresses each step a workflow author already has a word for: sequence, map, fan-out, fork, publish and subscribe, join, gather, reduce, compose, filter, route, branch, decide, exactly once, aggregate, deadline, loop, compensation, the human step, the model step, and the planner. The developer guide explains each piece of AgentSpaces on its own; this guide shows the pieces working together on one example, and at every step it names the options you have and what each one costs.

Every snippet comes from code that the repository checks in and runs under the build gate, and the first line of every snippet names the file and the method it comes from. The main example is example 18, the claims flow, in the AgentSpaces repository under examples/example-18-agentic-workflow. Where the claims flow does not use an option, the guide shows that option from another checked-in example or test, and the snippet names that source instead.

The guide uses two vocabularies, and keeps them apart. The first is the claims flow's own: its records, which are plain Java records such as Claim and PricedClaim, and its agents, which are plain Java classes such as Registrar and Payer. The second is the AgentSpaces API: spaces, entries, leases, annotations, and the runtime that runs them. Chapter 0 defines both before any snippet uses them.

The quick start, read as a flow

The developer guide's quick start has two classes, and they already form a two-stage workflow. The Fulfiller class takes an Order record and returns a Shipment record. The Auditor class reacts to every Shipment and returns a Receipt. Neither class names the other. The method signatures are the workflow: the parameter type says which record the method waits for, the return type says which record the method produces, and the space carries one record to the next method.

// agentspaces/examples/example-11-quickstart -- Quickstart.Fulfiller and Quickstart.Auditor
@SpaceTake(lease = "10m", pollTimeout = "300ms")
public Shipment ship(Order order) {
    space.write(new Progress(order.orderId(), "picking " + order.item()),
            Lease.of(Duration.ofMinutes(5)));
    return new Shipment(order.orderId(), order.item(), "fulfiller");
}

@SpaceNotify
public Receipt account(Shipment shipment) {
    return new Receipt(shipment.orderId(),
            shipment.item() + " shipped by " + shipment.by());
}

Every pattern in this guide ends the same way: a method returns a record, and the AgentSpaces runtime writes that record into a space as an entry. A stage never calls the next stage. It writes what it knows, and whichever stage consumes that record type picks the entry up, on whichever peer hosts that stage. That one rule is what lets the patterns compose without a workflow engine.

aspace://workflow/vocabulary

0. Two vocabularies: the claims flow and the AgentSpaces API

The claims flow

Example 18 is a claims department. A claimant files a claim against a policy. The department registers the claim, screens it for fraud, checks the policy is in force, estimates the damage, assembles those findings into one assessment, prices the assessment, asks three repair shops for quotes, puts the priced claim to a panel, pays the claims the panel approves, keeps a ledger of every outcome, and watches the fleet's average reserve. Three peers run it, and five spaces carry it. The first figure shows the flow as the records move between the spaces; the second shows the records themselves with their fields; the table after them lists every record the flow writes, and each chapter repeats the rows it uses.

INTAKE SPACEASSESSMENT SPACEDECISIONS SPACEPAYMENTS SPACELEDGER SPACEClaimRegistrar (take)RegisteredClaimRejectedRegistrar, when thedata is incongruentFraudFindingFraudScreen (react)CoverageFindingCoverageCheck (react)DamageFindingDamageEstimator (react)Stage 3a, after the assessment exists (dashed: the QuoteDesk reacts to it from the lane below):QuoteRequestQuoteTask ×3a shop per peer (take)RepairQuote ×3QuoteGatherer (join)RepairEstimate →to the ledgerQuoteDesk forksone request, three tasksAssembler (join on claimId)ClaimAssessmentPricedClaimSeniorAdjuster or JuniorAdjuster(auction take, by bid)SeniorReviewer reacts to severity=HIGHonly; its review goes to the ledgerUnderwriter (@Propose)ProposalPanelist ×3 (@Ballot)Ballot ×3quorum 3DecisionReminderUnderwriter writes the Reminder with the deadline as its lease;the Escalator reacts to its EXPIRED event and opens a MotionThe approver writes a ClaimDenied straight to the ledger when the panel denies.Approver (@OnDecision)PaymentOrderPayer (ordered take)PaymentExposure (@SpaceReduce)ClaimantExposureThe payments space runs the ordered log; a payment happens exactly once.Ledger (react)ClaimClosed PAIDClaimClosed REJECTEDLedger (react) from the RejectedClaimClosed DENIEDLedger (react) from a ClaimDeniedSeniorReviewRepairEstimateReserveReportSupervisor (@OnEstimate), once the fleet average settlesEvery record carries claimId except the fleet-wide ReserveReport. A solid arrow means the named agent turns the recordat the tail into the record at the head. A dashed arrow means the agent reads a record it does not consume.Verbs in brackets: take = @SpaceTake, react = @SpaceNotify, join = @SpaceJoin, ordered take = @OrderedTake.
The claims flow across its five spaces. Each lane is a space; each box is a record the flow writes; each arrow names the agent that produces the record at its head from the record at its tail, and the AgentSpaces verb that agent uses. Chapters 2 to 10 follow the arrows.
The records with their fields. The bold field is the correlation key the flow threads through every stage.ClaimclaimIdpolicyId (by id)claimantdescriptionclaimedAmountRejectedclaimIdreasonReminderclaimId(lease = the deadline)RegisteredClaimclaimIdpolicyIdclaimantdescriptionclaimedAmountregisteredByFraudFindingclaimIdfraudScorerationale, byCoverageFindingclaimIdcoveredcoverageLimit, byInstanceid, type, typeIriproperties {claimId, estimate, severity, by}tags: claimId, rdf:typeClaimAssessmentclaimIdclaimant, claimedAmountdamageEstimate, severityfraudScorecovered, coverageLimitassembledByPricedClaimclaimIdclaimantreserverecommendationpricedByQuoteRequestclaimIdexpectedQuoteTaskclaimIdshop (routes the take)estimateRepairQuoteclaimIdshop, amountRepairEstimateclaimIdshop, amountconsideredPaymentOrderclaimIdclaimantcentsPaymentclaimIdclaimant, centspaidBy, logIndexClaimantExposureclaimant (the key here)paidCentspaymentsClaimDeniedclaimIdreasonClaimClosedclaimIdoutcomesummarySeniorReviewclaimIdnoteReserveReportepoch (no claimId: a fleet-wide figure)averageReserveRegistrarthe three specialists,then the AssembleradjustersQuoteDesk forksa shopQuoteGathererApprover, after the panel's votePayerExposureApprover, when deniedLedgerSeniorReviewerThe Underwriter writes the Reminderwhen it opens the vote (chapter 10).An arrow means the named agent reads the record at its tail and writes the record at its head. A policy never travels inside a record;policyId names it, and a stage that needs it reads the policy's own entry. ClaimantExposure sums payments across a claimant's claims, so its key is the claimant.Instance carries claimId as a tag, because it travels as an ontology individual rather than a record of its own (chapter 12).
The record model. Every record is a plain Java record in Claims.java. The bold field is the key the flow threads through every stage.
Record (the claims flow's POJO)What it means in the businessWho writes itWho reads itSpace
Claima claimant's claim against a policy: claim id, policy id, claimant, description, amountthe application that files claimsthe Registrarintake
RegisteredClaima claim the department accepted; the cue for every specialistthe Registrarthe FraudScreen, CoverageCheck, DamageEstimator, and the Assemblerintake
Rejecteda claim the department refused at the door, with the reasonthe Registrarthe Ledgerintake
FraudFindinga fraud score for one claimthe FraudScreenthe Assemblerintake
CoverageFindingwhether the policy covers the claim, and up to what limitthe CoverageCheckthe Assemblerintake
Instance (a DamageFinding)the damage estimate and severity, carried as an ontology individual rather than a record of its own (chapter 12)the DamageEstimatorthe Assemblerintake
ClaimAssessmentthe three findings and the claim, composed into one recordthe Assemblerthe SeniorAdjuster, JuniorAdjuster, QuoteDesk, and SeniorReviewerassessment
PricedClaimthe reserve and a recommendation to approve or denyan adjusterthe Underwriter, the Panelists, the Approver, the ReserveSensorassessment
QuoteRequest, QuoteTaska request for repair quotes saying how many to expect, and one task per shopthe QuoteDeskthe shops and the QuoteGathererintake
RepairQuote, RepairEstimateone shop's quote; the lowest of the quotes gathereda shop; the QuoteGathererthe QuoteGatherer; the ledger's readersintake; ledger
SeniorReviewa senior reviewer's note on a severe claimthe SeniorReviewerthe ledger's readersledger
Reminderthe panel's deadline for one claim, as a leased entrythe Underwriterthe Escalator, when it lapsesdecisions
PaymentOrder, ClaimDeniedthe panel's approval, as an order to pay; or its denialthe Approverthe Payer; the Ledgerpayments; ledger
Paymenta payment made, with the log index that admitted itthe Payerthe Ledger and the Exposure stagepayments
ClaimantExposurea claimant's running total of paymentsthe Exposure stageanyonepayments
ClaimClosedthe terminal record of one case: paid, denied, or rejectedthe Ledgeranyone, long after the flowledger
ReserveReportthe fleet's settled average reservethe Supervisoranyoneledger

The AgentSpaces API

These are the AgentSpaces terms the chapters use. The figure shows what they look like in example 18, and the table defines each one. Each chapter repeats a definition the first time it needs the term, so you can read the chapters out of order.

One group, three peers. Every peer holds a replica of every space; the binder on each peer runs the agents bound there.peer-aone process running the AgentSpaces library, a member of the group claims-fleetreplicasintakeassessmentdecisionspaymentsledgerAgentBinderreads each bound agent's annotations and runs its takes, reactions, joins, and foldsRegistrarFraudScreenAssemblerSeniorAdjusterQuoteDeskNorthShopUnderwriterEscalatorPanelistApproverPayerReserveSensorcapabilitiesvote, on the decisions spaceaggregate (push-sum gossip)ordered log, on the payments spacegossip: the replicas converge; the capabilities talk over pipespeer-bone process running the AgentSpaces library, a member of the group claims-fleetreplicasintakeassessmentdecisionspaymentsledgerAgentBinderreads each bound agent's annotations and runs its takes, reactions, joins, and foldsRegistrarCoverageCheckJuniorAdjusterEastShopSeniorReviewerPanelistPayerReserveSensorcapabilitiesvote, on the decisions spaceaggregate (push-sum gossip)ordered log, on the payments spacegossip: the replicas converge; the capabilities talk over pipespeer-cone process running the AgentSpaces library, a member of the group claims-fleetreplicasintakeassessmentdecisionspaymentsledgerAgentBinderreads each bound agent's annotations and runs its takes, reactions, joins, and foldsDamageEstimatorSouthShopQuoteGathererPanelistPayerReserveSensorLedgerExposureSupervisorcapabilitiesvote, on the decisions spaceaggregate (push-sum gossip)ordered log, on the payments spaceAn entry written on peer-a reaches the agents on peer-c through the replicas. A @SpaceTake method on one peer competes with the same method on another peer for each entry;a @SpaceNotify method runs on every peer that hosts its agent. The seating above is ClaimsFlow.staff, which chapter 13 shows.
The runtime model. What the AgentSpaces terms in the table below look like in example 18: three peers in one group, a replica of each space on every peer, one binder per peer running the agents seated there, and the three capabilities every peer registers.
AgentSpaces termWhat it is
peerone process that runs the AgentSpaces library and joins a group. Example 18 runs three peers, named peer-a, peer-b, and peer-c.
groupthe set of peers that share spaces. Every peer in example 18 joins the one group claims-fleet.
spacea named, replicated bag of entries. A space has one conflict strategy and one retention policy, decided when the peer builds it. Example 18 has five: intake, assessment, decisions, payments, and ledger.
replicaone peer's copy of a space. Every peer in the group holds a replica of every space, and the peers keep the replicas converging by gossip.
entryone record written into a space, wrapped with its issuer, its lease, its tags, and a signature. When the guide says "the Registrar writes a RegisteredClaim", it means the runtime writes an entry whose payload is that record.
leasehow long something lasts. A write lease is how long an entry stays in the space. A take lease is how long a worker may hold an entry it took before the space assumes the worker died and offers the entry again.
taga string label on an entry, beside the record. A replica can match tags before it decodes the record.
templatea query: a record type plus conditions on its fields and its tags. Every read, take, and subscription matches a template against the replica's entries.
takeclaiming one entry exclusively under a take lease, doing work, and completing it. Completing the entry removes it; completing it with a result writes the result in the same commit.
subscription, reactionasking a replica to deliver every new entry that matches a template to a method. The guide calls the delivered call a reaction. A reaction holds no lease and claims nothing.
annotationhow you tell the runtime what a method is: @SpaceTake makes the method a take, @SpaceNotify makes it a reaction, @BidFunction prices an auction, @Ballot and @OnDecision vote and react to the decision, @Propose opens a vote, @OnEstimate reacts to a settled estimate, @OrderedTake takes through the ordered log, @SpaceJoin a join, @SpaceReduce a fold. The method's parameter type is the record the method waits for; its return type is the record the method produces. On the class, @AgentSpec (or the Spring and Micronaut @SpaceAgent stereotype) names the agent and its card; on a field, @SpaceRef injects a space and @CapabilityRef a typed client of a capability (VoteClient, AggregateClient); on a bean, @ProvidesCapability serves one. The developer guide has every attribute.
the binderthe runtime object, AgentBinder, that you give your annotated object to with bind(agent). The binder reads the annotations and then runs everything the annotations imply: the take loops, the subscriptions and their renewals, the joins, the folds, the votes. When the guide says "the binder writes the result", it means this object.
agentan annotated Java object the binder has bound. Example 18's agents are the classes in the records table: Registrar, FraudScreen, Assembler, and the rest.
cardthe advertisement the binder publishes for each bound agent, named by its @AgentSpec: which record types its methods consume and produce, and where, with one declared action per bound method. Planners and other peers read cards to find stages.
capabilitya service a peer offers the group beyond the core: the vote, the aggregate, the ordered log. Example 18 registers all three on every peer.
coordinator, the ordered logthe capability that elects a quorum among the peers and serializes takes through a log, so that a take happens at most once across the whole group.
ticketa small entry, JoinTicket, that the binder writes for one key of a join or a fold and then takes, so that only the taker acts on that key.
keythe value that identifies one case across many entries. In example 18 the key is almost always the claimId field.
aspace://workflow/protocol

1. Entries are the protocol

Where we are in the claims flow

Before any stage runs, example 18 decides what its records look like and which space each one lives in. This chapter is that decision. A workflow engine would hold a process definition and a history; the claims flow holds neither. The records that travel between its stages are the whole of its protocol, so the shape of those records is the design.

The records in play

Record (the claims flow's POJO)What it means in the businessWho writes itWho reads itSpace
Claimwhat a claimant files: the claim id, the policy id, the claimant's name, a description, and the amount claimedthe application that files claimsthe Registrarintake
RegisteredClaimthe same claim after the department accepted it, plus who registered itthe Registrarthree specialists and the Assemblerintake
ClaimAssessmentthe claim and its three findings in one record: the damage estimate, the severity, the fraud score, whether the policy covers it, and the coverage limitthe Assemblerthe adjusters, the quote desk, the senior reviewerassessment
// agentspaces/examples/example-18-agentic-workflow -- Claims: three of the records
/** What arrives: a claim against a policy. */
public record Claim(String claimId, String policyId, String claimant, String description,
                    double claimedAmount) {
}

/** Stage 1: the registrar's output, the cue for every specialist. */
public record RegisteredClaim(String claimId, String policyId, String claimant,
                              String description, double claimedAmount, String registeredBy)
        implements Registration {
}

/** Stage 3: the join of the three findings, priced at auction in stage 4. */
public record ClaimAssessment(String claimId, String claimant, double claimedAmount,
                              double damageEstimate, String severity, int fraudScore,
                              boolean covered, double coverageLimit, String assembledBy) {
}

What the design must do

Three rules shape every record in the flow, and every later chapter leans on them.

Every record carries the correlation id. The field claimId appears in every record above. A join keys on it to collect the findings of one claim, a fold groups by it, a compensating stage finds what to undo by it, and a test reads a case's outcome by it. A record without the case id is a record no later stage can connect to anything.

A reference is an id, never a nested object. Claim.policyId names the policy; it does not embed it. A stage that needs the policy reads the policy's own entry with a template on that id. The policy then lives in its own space, under its own lease, and a change to it does not invalidate every claim that mentions it.

One space per lane. The peer builds each space once, choosing its conflict strategy and its retention, and every stage inherits those choices by naming the space. In the snippet below, the assessment space uses the AUCTION strategy because the adjusters in chapter 6 bid for assessments; the other four use the default lease race.

// agentspaces/examples/example-18-agentic-workflow -- ClaimsFlow.startPeer: one space per lane
// One space per lane: the strategy, the retention and the admission are
// decided here, once, and every stage inherits them by naming the space.
ReplicatedSpace intake = space(runtime, identity, name, Intake.SPACE);
ReplicatedSpace assessment = ReplicatedSpace.builder(runtime, Pricing.SPACE, identity, name)
        .settleWindow(Duration.ofMillis(500)) // two ticks: a rival's reaction time plus delivery
        .strategy(ConflictStrategyType.AUCTION)      // the bound @BidFunction prices takes
        .build();
ReplicatedSpace decisions = space(runtime, identity, name, Decisions.SPACE);
ReplicatedSpace payments = space(runtime, identity, name, Settlement.PAYMENTS);
ReplicatedSpace ledger = space(runtime, identity, name, Settlement.LEDGER);

The two failure models

Every stage in the flow runs under one of two contracts, and choosing between them is the first decision in every later chapter. The Registrar is a take: it claims a Claim entry under a lease, does its work, and completes the entry. If the registrar's method throws, or its peer dies, the lease lapses, the space offers the Claim entry again, and another registrar retries it. The FraudScreen is a reaction: every peer that hosts a FraudScreen delivers each RegisteredClaim entry to its copy of the method. If that method throws, the binder logs the exception and drops that delivery, and nothing retries it.

A take (@SpaceTake, @OrderedTake)A reaction (@SpaceNotify, and the annotations built on it)
Who runs the methodone worker: whichever agent wins the claim on the entryevery peer that hosts the agent
What happens on a crashthe take lease lapses, the entry reappears, another worker retriesthe binder logs and drops the delivery; the entry stays; nothing retries
Atomicitythe completion of the input and a result written to the same space are one committhe binder writes the result as a plain write after the method returns
Cost per entryone claim, which waits one settle window on a replicated spacedelivery only
Example 18 uses it forthe Registrar, the adjusters, the shops, the Payerthe three specialists, the SeniorReviewer, the Ledger, the ReserveSensor
Choose it whenthe work must happen once and may failthe work is an observation, a fan-out, or anything you can safely repeat
How to choose between a take and a reactionAsk two questions about the stage. Must its effect happen once across the group? If yes, write a take, because a take claims the entry and only one agent wins the claim. Can its work fail in a way that must retry? If yes, write a take, because a take's lease lapses on failure and the entry comes back. If the answer to both is no, write a reaction: it costs no claim, it runs wherever you bind the agent, and it is the natural shape for observing, labelling, and fanning out. Example 18 answers yes for registering, pricing, quoting, and paying, and no for screening, checking, estimating, reviewing, closing the ledger, and sensing.
A rule every chapter repeatsA reaction runs on every peer that hosts the agent. If you bind the FraudScreen on three peers, three fraud screens score every claim. Example 18 binds each specialist on one peer. When a reaction's effect must happen exactly once across the group, use a take instead, or a join in a fleet-wide mode (chapter 4), or a vote (chapter 7), or write a record that carries the case id so the next stage can discard duplicates.
aspace://workflow/sequence

2. Sequence and recovery

Where we are in the claims flow

Stage 1. A Claim entry sits in the intake space. The department must register it, exactly once, and the registration must survive the registrar crashing halfway through. Example 18 runs two registrars, on peer-a and peer-b, and its test makes the first registration any registrar attempts throw, on purpose.

The records in play

Record (the claims flow's POJO)What it means in the businessWho writes itWho reads itSpace
Claimthe filed claimthe filing applicationthe Registrarintake
RegisteredClaimthe accepted claimthe Registrarthe specialistsintake
Rejecteda claim refused at the door: an amount of zero, or no policythe Registrarthe Ledgerintake
Registrationa sealed interface that RegisteredClaim and Rejected implement: the registrar's two possible outcomesnobody; it is a typethe card (chapter 6)

What the stage must do

Take one Claim, check the vocabulary's constraints, and write either a RegisteredClaim or a Rejected. Consume the Claim and write the outcome together, so a crash cannot leave a claim consumed with no outcome, or an outcome written with the claim still waiting.

How AgentSpaces does it

// agentspaces/examples/example-18-agentic-workflow -- Intake.Registrar.register
@SpaceTake(space = SPACE, lease = "3s", pollTimeout = "300ms", resultLease = "1h")
public Registration register(Claim claim) {
    hook.beforeRegistering(name, claim);
    // The vocabulary's constraints, checked where the data enters: a claim
    // that fails them is quarantined as a Rejected entry, which the ledger
    // closes, rather than thrown away.
    if (claim.claimedAmount() <= 0) {
        return new Rejected(claim.claimId(), "claimedAmount must be positive");
    }
    if (claim.policyId() == null || claim.policyId().isBlank()) {
        return new Rejected(claim.claimId(), "a claim names its policy");
    }
    return new RegisteredClaim(claim.claimId(), claim.policyId(), claim.claimant(),
            claim.description(), claim.claimedAmount(), name);
}

The annotation @SpaceTake tells the binder that register is a take. The binder reads the parameter type, Claim, and runs a loop on a virtual thread: it asks the intake space for one Claim entry nobody else holds, claims it under a three-second take lease, calls register with the record, and when the method returns, completes the Claim entry and writes the returned record into the same space under a one-hour write lease, in one commit. The hook is the example's own test device, a callback the test uses to throw once; it is not part of AgentSpaces. When the hook throws, register throws, the binder does not complete the entry, the three-second lease lapses, and the other registrar's loop takes the same Claim and registers it. The test counts one crash and five registrations.

The options

Option 1: write the result into the take space. Leave resultSpace empty, as the Registrar does. The binder then completes the Claim and writes the Registration in one commit, and the next stage takes or reacts from the same space. This is the default, and the guide prefers it wherever the next stage can live in the same lane.

Option 2: write the result into another space. Name a resultSpace. The binder completes the input bare and then writes the result as a second step. Choose it when the result belongs to a space with a different strategy or retention, and accept that a crash between the two steps loses the result; carry the input's id in the result so the next stage can spot the gap. The Party Bus watcher below does this because its reports live in a signals space with their own retention.

Option 3: renew the lease from inside the method. Ask for a short lease and call TakeContext.current().renew() while the work runs. Choose it when one call can take a hundred times longer than another, such as a model call, so that an honest slow call keeps its entry while a dead worker's entry still returns quickly. The test recorder below shows the call.

Option 4: make the effect safe to repeat and keep the lease short. This is your code, not an attribute. The registrar registers the same claim twice harmlessly because the Assembler keys on the claim id, so a three-second lease is safe. AgentSpaces cannot supply this discipline; the method must.

The options at a glance

OptionAgentSpaces APIExample 18Choose it whenAlternative shown below
Write the result into the take spaceresultSpace left empty on @SpaceTakethe Registrar: the Claim and the RegisteredClaim both live in intakethe next stage can take or react from the same space; the completion and the result then commit togetherthe registrar above
Write the result into another spaceresultSpace = "..." on @SpaceTakethe shops in chapter 6 take a QuoteTask from intake and write the RepairQuote to intake too; the Party Bus watchers write elsewherethe result belongs to a space with a different strategy or retention; the binder then completes the input bare and writes the result as a second step, and a crash between the two loses the resultParty Bus NewsWatcher
Renew the take lease during long workTakeContext.current() inside the method, then renew()not used; every take in example 18 finishes in millisecondsone call can run far longer than another; ask for a short lease and renew it from inside the method rather than guessing a long oneTakeContextTest.Recorder
Make the effect idempotent and keep the lease shortyour own codethe registrar: registering the same claim twice writes the same record twice, and the join in chapter 4 keys on the claim ida retry after a crash must not double an external effect; the fabric cannot supply this discipline, your method mustthe registrar above
// agentspaces-partybus -- Watchers.NewsWatcher.sweep: a take whose result goes to another space
@SpaceTake(space = Scouts.SPACE, resultSpace = SIGNALS, lease = "1m",
        pollTimeout = "500ms")
public SignalReport sweep(NewsSweep sweep) {
    List<Link> news = sources.news(sweep.location() + " " + sweep.topic(), 10);
    return new SignalReport(sweep.tripId(), sweep.topic(), sweep.location(), "news",
            sweep.wave(), news.stream().map(Link::summary).toList(), news,
            "news-watcher");
}

In the Party Bus, a NewsWatcher takes a NewsSweep request from the research space and writes its SignalReport into the signals space, where a different retention applies. The binder completes the request first and then writes the report.

// agentspaces-agent tests -- TakeContextTest.Recorder.work: renewing the lease from inside the method
@SpaceTake(space = "tasks", lease = "2m", pollTimeout = "PT0.1S")
public FindingEntry work(TaskEntry task) {
    TakeContext context = TakeContext.current().orElseThrow();
    context.renew();
    seen.set(context);
    return new FindingEntry(task.topic(), "done");
}

TakeContext.current() returns the take the current method runs under. Calling renew() extends its lease by the annotation's lease again. If the renewal throws, the lease already lapsed and another worker may hold the entry, so the method should stop.

Choosing the take leaseThe take lease is the crash budget. A dead worker's entry stays invisible to every other worker for the whole lease, so a long lease turns one crash into a long stall; a lease shorter than the honest work makes the space hand a live worker's entry to a second worker, and both do the work although only one completes it. Size the lease at a few times the honest duration, use renew for the rare long call, and make the effect safe to repeat. Example 18 uses three seconds for registering, thirty for quoting and paying, and the Spring AI summarizer of chapter 11 uses two minutes.
aspace://workflow/fanout

3. Map, fork, fan-out, and publish-subscribe

Where we are in the claims flow

Stage 2 and stage 3a. A RegisteredClaim entry exists. Three specialists must each examine it and write their own finding: the FraudScreen a FraudFinding, the CoverageCheck a CoverageFinding, the DamageEstimator a damage finding. Later, once an assessment exists, the QuoteDesk must ask three repair shops for quotes, which means writing one request and three tasks from one assessment.

The records in play

Record (the claims flow's POJO)What it means in the businessWho writes itWho reads itSpace
RegisteredClaimthe accepted claim; the cue every specialist waits forthe Registrarthe three specialistsintake
FraudFindinga fraud score from 0 to 100 and a rationalethe FraudScreenthe Assemblerintake
CoverageFindingwhether the policy is in force, and its limitthe CoverageCheckthe Assemblerintake
ClaimAssessmentthe assembled findingsthe Assemblerthe QuoteDesk, among othersassessment
QuoteRequesta request for quotes on one claim, stating how many quotes to expectthe QuoteDeskthe QuoteGathererintake
QuoteTaskone quote task for one shop, naming the shop and the damage estimatethe QuoteDeskthe shop the task namesintake

Map: one record in, one record out

In a stream library, map applies one function to every element. Here the elements are the entries of one record type in one space, and the function is a method you annotate. The CoverageCheck maps every RegisteredClaim to a CoverageFinding:

// agentspaces/examples/example-18-agentic-workflow -- Intake.CoverageCheck.check
@SpaceNotify(space = SPACE, resultLease = "1h")
public CoverageFinding check(RegisteredClaim claim) {
    boolean covered = !claim.policyId().endsWith("-LAPSED");
    return new CoverageFinding(claim.claimId(), covered, COVERAGE_LIMIT, "coverage-check");
}

@SpaceNotify tells the binder that check is a reaction. The binder subscribes to the intake space for RegisteredClaim entries, and each time the peer's replica receives one, the binder calls check with the record on a virtual thread and writes the returned CoverageFinding into the same space under a one-hour lease. The binder also renews the subscription's lease for as long as the agent stays bound, and it delivers each entry to this agent once, even when gossip delivers the same entry twice.

Fan-out and publish-subscribe: several maps on one cue

Fan-out is the map above written three times for three record types. The FraudScreen, the CoverageCheck, and the DamageEstimator each declare RegisteredClaim as their parameter, so each of them reacts to every registered claim, and each returns its own finding. None of the three knows the other two exist, and the Registrar that writes the RegisteredClaim knows none of them. Example 18 binds the FraudScreen on peer-a, the CoverageCheck on peer-b, and the DamageEstimator on peer-c, and the replicas carry every registered claim to all three.

That is also publish and subscribe. The Registrar is the publisher, the RegisteredClaim record type is the topic, each specialist is a subscriber, and the space is the broker that is not there. The table at the end of this chapter maps every broker term onto this example.

Fork: one record in, several records out

The QuoteDesk reacts to one ClaimAssessment and must write four records: a QuoteRequest that tells the gatherer to expect three quotes, and three QuoteTasks, one per shop. A method has one return value, so it returns an Entries, which is the AgentSpaces class for "several entries from one return":

// agentspaces/examples/example-18-agentic-workflow -- Quotes.QuoteDesk.ask
@SpaceNotify(space = Pricing.SPACE, resultSpace = Intake.SPACE, resultLease = "1h",
        produces = {QuoteRequest.class, QuoteTask.class})
public Entries ask(ClaimAssessment assessment) {
    return Entries.of(new QuoteRequest(assessment.claimId(), SHOPS.size()),
            new QuoteTask(assessment.claimId(), "north", assessment.damageEstimate()),
            new QuoteTask(assessment.claimId(), "east", assessment.damageEstimate()),
            new QuoteTask(assessment.claimId(), "south", assessment.damageEstimate()));
}

Four things happen when ask returns, and each one deserves its own sentence.

The binder writes each element on its own. The binder walks the four elements in order and writes each one into the intake space as a separate entry under the one-hour result lease, exactly as it would have written a single returned record. The three shop classes in chapter 6 then take the three QuoteTask entries one at a time, and the QuoteGatherer in chapter 4 reads the QuoteRequest.

An element may be any return kind the binder knows. Because the binder dispatches each element as if the method had returned it alone, an element may be a plain record, a Tagged record (a record plus the tags to write on its entry, chapter 12), a Motion (an instruction to open a vote, chapter 7), or a Contribution (a value for a fleet-wide aggregate, chapter 9). The binder refuses an Entries inside an Entries.

From a take, the binder completes the input before it writes the first element. If the forking method were a @SpaceTake, the binder would complete the taken entry once with no result, then write the elements. A take's one-commit guarantee covers one result, and a fork has several. The QuoteDesk is a reaction, so the binder completes nothing and only writes.

A fork is not atomic, so the flow tolerates a partial one. A peer can die after writing the second element and before the third, and no engine rolls that back. Two habits make a partial or repeated fork harmless. First, every forked record carries the case id: each QuoteTask and the QuoteRequest carry claimId. Second, the stage downstream discards duplicates by that id: the QuoteGatherer fires once per claimId, so a repeated fork yields one estimate.

The annotation tells the card what the fork produces. The binder publishes a card for every agent, listing the record types each method consumes and produces, by reading the method signature. A signature that says Entries names no record type, so the method lists its element types in produces. That changes no behaviour; it keeps the card honest.

Map to nothing: a null return

A reaction that decides it has nothing to write returns null, and the binder writes nothing. The Escalator in chapter 10 does this when the panel has already decided. Use a null return when the decision needs a lookup; when the decision is a field value, use the where filter of chapter 6 instead, because the filter keeps the method from running at all.

The options

Option 1: map with a take. Annotate the method @SpaceTake and return the next record. One worker handles each entry, and a crash retries it. The Registrar in chapter 2 is this shape. Choose it when the output must exist once or the work can fail.

Option 2: map with a reaction. Annotate the method @SpaceNotify and return the next record, as the CoverageCheck does. Every peer that hosts the agent runs it. Choose it when you bind the agent once, or when a repeated output is harmless.

Option 3: fork through the return value. Return Entries.of(...) and list the element types in produces, as the QuoteDesk does. Choose it when one record starts several tasks and you know the elements when the method returns; the card then lists them.

Option 4: fork through explicit writes. Declare a @SpaceRef field, which asks the binder to inject a Space handle at bind time, and call space.write(record, lease) as many times as you need. The Party Bus Dispatcher below does this. Choose it when the elements go to several spaces or the method must read between writes; the card cannot list what you write by hand.

Option 5: map to nothing. Return null, and the binder writes nothing. The PetClinic Vet below and the Escalator of chapter 10 do this. Choose it when the decision needs a lookup; when the decision is a field value, prefer the where filter of chapter 6, which keeps the method from running.

Option 6: map over a backlog. A subscription only delivers entries that arrive after it starts, so a reaction bound late misses the backlog. A take sees every entry still in the space, so prefer a take for a backlog; when you must sweep from a reaction, call space.readAll(template, limit) through a @SpaceRef field.

The options at a glance

OptionAgentSpaces APIExample 18Choose it whenAlternative shown below
Map with a take: once per entry, with retry@SpaceTake returning the next recordthe Registrar (chapter 2)the output must exist once, or the work may fail and must retrychapter 2
Map with a reaction: on every hosting peer@SpaceNotify returning the next recordthe CoverageCheck aboveyou bind the agent once, or the output is harmless to repeatabove
Fork through the return valueEntries.of(...) with produces on the annotationthe QuoteDesk aboveone record starts several tasks and the elements are known when the method returnsabove
Fork through explicit writesa @SpaceRef field holding a Space, and space.write(...) in the methodnot used in example 18; the Party Bus Dispatcher writes nine requests this waythe elements go to several spaces, or the method must read something between writes; the card cannot list what you write by handParty Bus Dispatcher
Map to nothingreturn nullthe Escalator (chapter 10), the PetClinic Vetthe decision needs a lookup the template cannot expressPetClinic Vet
Map over entries already in the spacespace.readAll(template, limit) and a loopnot useda subscription only delivers entries written after it starts; to process a backlog, prefer a take, which sees every entry still in the space
// agentspaces-partybus -- Dispatcher.dispatch: a fork by explicit writes through @SpaceRef fields
@SpaceRef(Scouts.SPACE)
Space research;
@SpaceRef(Party.TRIP)
Space trip;

/** Fan a brief out into the bag, the sweeps, and the auction. */
@SpaceNotify(space = Party.TRIP, lease = "12h")
public void dispatch(TravelBrief brief) {
    String id = brief.tripId();
    Travelers party = trip.read(Template.of(Travelers.class).where("tripId", eq(id)),
            Duration.ofSeconds(10)).orElseThrow(() -> new IllegalStateException(
            "no travelers written for trip " + id));
    List<String> needs = party.allNeeds();
    String who = party.contribution();
    double nightly = brief.dailyBudget() / 2.0; // Tripper's half-the-budget rule

    research.write(new FlightRequest(id, "flights " + brief.from() + " to " + brief.to(),
            brief.from(), brief.to(), brief.departureDate(), brief.returnDate(),
            brief.transportPreference()), REQUEST_LEASE);
    research.write(new HotelRequest(id, "hotels " + brief.to(), brief.to(),
            brief.departureDate(), brief.returnDate(), nightly, needs), REQUEST_LEASE);

The Party Bus Dispatcher reacts to a TravelBrief, reads the Travelers record for the trip first, and then writes nine research requests by hand through @SpaceRef fields. @SpaceRef asks the binder to inject a Space handle into the field at bind time. This shape lets the method read between writes and write to several spaces; it costs the card, which cannot see the nine types.

// agentspaces-example-apps/agentspaces-petclinic-fleet -- PetClinicFleet.Vet.see: a reaction that returns null
@SpaceNotify(space = CLINIC, lease = "4h", resultLease = "4h")
public VisitRecord see(Appointment appointment) {
    if (!appointment.vet().equals(vetName)) {
        return null; // not mine
    }
    String[] outcome = diagnose(appointment.petName());
    return new VisitRecord(appointment.requestId(), appointment.petName(), vetName,
            outcome[0], outcome[1]);
}

In the PetClinic, both vets react to every Appointment, and each returns null for an appointment that names the other vet. A where = "vet=dr-song" filter would do the same without calling the method, but it would need one class per vet, since an annotation value is a constant.

Publish and subscribe, term by term

Broker termWhat it is in the claims flow
Topica record type in a space. RegisteredClaim in intake is the topic the three specialists subscribe to. Tags on an entry are sub-topics.
Publishwriting an entry. The Registrar publishes by returning a RegisteredClaim; any code, in any language, publishes with Space.write(record, lease).
Subscribea @SpaceNotify method whose parameter is the topic's record type, narrowed by tags and where if needed. In the API, Space.notify(template, listener, lease).
Delivery to every subscriberevery replica delivers every matching entry to every subscription it holds. Three specialists on three peers all receive every RegisteredClaim.
Consumer group, one of Na @SpaceTake. The two registrars form a consumer group over Claim: each claim goes to one of them.
Retained messagethe entry itself, for its write lease. A specialist that binds after the registrar wrote a claim can still read that claim with readAll, because a subscription only delivers the entries that arrive after it starts.
Durable subscription, replayAgentSpaces does not provide one. The subscription holds a lease and starts now; the entry is the durable thing; an agent that restarts reads the space.
At least once, deduplicateda replica may deliver an entry twice; the binder delivers each entry id to each agent once.
Orderingfirst in, first out among entries of one type on one peer; no order across peers or types. The ordered log of chapter 8 gives order when order matters.
Wildcard, hierarchytags. tags = "region" subscribes to every entry with that tag; tags = "region=eu" to one value. A template names one concrete record type and never an interface.
Lifecycle events@SpaceNotify(on = EXPIRED) and the other kinds: the subscriber hears an entry lapse, reappear, or complete (chapter 10).

Four rules for a subscriber

A subscriber, in this guide, is an agent with a @SpaceNotify method. That method is the reaction; the type of its parameter is the cue type, the record it listens for; and the record it returns goes into a space under the result lease named on the annotation. Each peer holds its own replica of every space, and the peer that hosts the subscriber delivers matching entries to it from that replica. A broker would enforce the four rules below for you. Here they are yours, and each one follows from how the replica delivers.

1. A reaction runs on every peer that hosts the subscriber. If you bind the FraudScreen on three peers, each peer delivers each RegisteredClaim to its own copy, so three fraud findings appear per claim. That is what makes fan-out cheap, and it is a problem only when the reaction's effect must happen once. Three remedies, in order of preference: bind the subscriber on one peer, as example 18 binds the FraudScreen on peer-a alone; return a Motion, which opens a vote once per proposal id however many peers return it; or write a record that carries the case id and let the next stage discard duplicates by it, as the Assembler does. A reaction whose repeated effect is harmless needs none of these.

2. The binder drops a reaction that throws, and nothing retries it. A take holds a lease, so when its method throws the lease lapses and the entry comes back for another worker. A reaction holds nothing: the replica delivered the entry, nobody claimed it. When the method throws, the binder logs the exception and never delivers that entry to this subscriber again. If the work can fail and must still happen, write the stage as a @SpaceTake; if it may fail harmlessly, catch what you can and let the rest go.

3. A reaction must not write its own cue type. The subscriber sees every entry of its cue type in the replica, including the entries this same process wrote a moment ago. A reaction that listens for X and returns an X delivers to itself forever. Return a different type, as every reaction in example 18 does, or when the type cannot change, put a field on the record that the reaction never produces and filter on it with where (chapter 6).

4. The result lease is how long the publication stays. The CoverageFinding stays in the intake space for one hour and then every replica drops it. A subscriber that starts late can still read it with readAll while the hour lasts; after that nothing replays it. When a record must outlive the flow, write it into a space of its own under a long lease: the Ledger writes every ClaimClosed into the ledger space under a twenty-four-hour lease, and the short-lived lanes stay short-lived.

aspace://workflow/join

4. Join, gather, and reduce

Where we are in the claims flow

Stage 3, stage 3a, and stage 7a. The three findings of one claim land in the intake space on three different peers, in any order, and the Assembler must wait for all three before it can write the ClaimAssessment. Later, three shops write three RepairQuotes per claim and the QuoteGatherer must wait for exactly three before choosing the lowest. Later still, payments land one at a time and the Exposure stage must keep each claimant's running total current after every payment.

The records in play

Record (the claims flow's POJO)What it means in the businessWho writes itWho reads itSpace
RegisteredClaim, FraudFinding, CoverageFinding, the damage Instancethe four parts of one claim, each written by a different agent on a different peerthe Registrar and the three specialiststhe Assemblerintake
ClaimAssessmentthe four parts composed into one recordthe Assemblerthe adjusters and the quote deskassessment
QuoteRequesthow many quotes to expect for one claimthe QuoteDeskthe QuoteGathererintake
RepairQuoteone shop's quote for one claima shopthe QuoteGathererintake
RepairEstimatethe lowest quote, and how many were consideredthe QuoteGathererthe ledger's readersledger
Paymentone payment to one claimantthe Payerthe Exposure stage and the Ledgerpayments
ClaimantExposurea claimant's running total of payments, and their countthe Exposure stageanyonepayments

Join: all the parts of one case

What the stage must do

Fire once per claim when the RegisteredClaim, the FraudFinding, the CoverageFinding, and the damage finding for that claim are all present, whatever order they arrived in and whichever peer wrote them, and write one ClaimAssessment. In relational terms this is an equi-join of four record types on the one key claimId.

How AgentSpaces does it

// agentspaces/examples/example-18-agentic-workflow -- Intake.Assembler.assemble
@SpaceJoin(space = SPACE, key = "claimId", resultSpace = Pricing.SPACE, resultLease = "1h",
        parts = {@Part(RegisteredClaim.class), @Part(FraudFinding.class), @Part(CoverageFinding.class),
                 @Part(value = Instance.class, keyTag = "claimId",
                       tags = "rdf:type=" + Claims.NAMESPACE + Instance.DAMAGE_FINDING)})
public ClaimAssessment assemble(Joined j) {
    RegisteredClaim claim = j.get(RegisteredClaim.class);
    Instance damage = j.get(Instance.class);
    return new ClaimAssessment(j.key(), claim.claimant(), claim.claimedAmount(),
            damage.number("estimate"), damage.string("severity"),
            j.get(FraudFinding.class).fraudScore(), j.get(CoverageFinding.class).covered(),
            j.get(CoverageFinding.class).coverageLimit(), name);
}

@SpaceJoin tells the binder that assemble is a join. The annotation lists the parts: four @Part declarations, each naming a record type the join waits for. key = "claimId" names the field every part carries, except the damage finding, which is an Instance record with no claimId field; for that part keyTag = "claimId" tells the binder to read the key from a tag on the entry, and tags = "rdf:type=..." tells it to consider only instances of the damage-finding class. The binder subscribes to all four record types in the intake space, keeps a table of which parts it has seen per claimId, and when a claim has all four, reads the four entries again, wraps them in a Joined bag, and calls assemble on a virtual thread. Joined.get(type) returns the newest part of a type; Joined.key() returns the claim id. The binder writes the returned ClaimAssessment into the assessment space.

The binder also seeds its table from the space when the agent binds, so parts that landed before the Assembler started still count, and it forgets a claim whose parts stop arriving after within, one hour by default.

The options: how many times one claim fires

Example 18 binds one Assembler on peer-a. If you bind an Assembler on every peer, each one sees every claim complete, and the question becomes how many times one claim may fire across the group. That is the join's mode, and it is the join's one real trade-off.

LOCAL, the default. The binder remembers the keys it fired in memory and writes nothing before the result. Each bound joiner fires each claim once, so bind one Assembler, as example 18 does, or let the next stage discard a duplicate by the claim id. A restarted joiner re-reads the space and may fire a claim again.

LEASED. When a claim completes, the binder writes a JoinTicket entry for it into the join's space, withdraws the younger of two tickets written in the same instant, and fires from a take of that ticket. Several joiners on several peers then share one ticket per claim, and the take's lease gives the firing a retry. Under a network partition two tickets may survive, which is the contract of the lease race; the test joiner below shows two joiners and a crash.

ORDERED. The binder takes the ticket through the ordered-log coordinator registered for the join's space, and the first ticket the log commits for a claim is the only one that fires, on whichever peer took it. This is once across the group, at the price of the log: a peer hosts one ordered log per group, so the OrderedAssembler below names the payments space, where example 18's log runs, as the join's space, and intake on each part.

The options at a glance

OptionAgentSpaces APIExample 18Choose it whenAlternative shown below
LOCAL: once per bound joinermode = SpaceJoin.Mode.LOCAL, the default; the binder keeps the fired keys in memory and writes nothing before the resultthe Assembler above, bound on one peeryou bind one joiner, or the next stage discards duplicates by the key; a restarted joiner re-reads the space and may fire a claim againabove
LEASED: once per lease race, per ticketmode = LEASED; when a claim completes, the binder writes a JoinTicket entry for it, withdraws the younger of two written in the same instant, and fires from a take of that ticketnot used in example 18several joiners on several peers, and a duplicate under a network partition is acceptable; this is the contract of the lease raceAgentBinderJoinTest.LeasedJoiner
ORDERED: once across the groupmode = ORDERED; the binder takes the ticket through the space's ordered-log coordinator, and the first ticket the log commits for a claim is the only one that firesthe OrderedAssembler, which the second test binds on peer-a and peer-bthe assessment must exist exactly once; costs the log, and a peer hosts one log per group, so the join lives on the space that has the coordinator while its parts stay where they arebelow
// agentspaces/examples/example-18-agentic-workflow -- Intake.OrderedAssembler: the same join, once fleet-wide, on the payments log
@SpaceJoin(space = Settlement.PAYMENTS, key = "claimId", mode = SpaceJoin.Mode.ORDERED,
        takeLease = "30s", pollTimeout = "500ms", resultSpace = Pricing.SPACE, resultLease = "1h",
        parts = {@Part(value = RegisteredClaim.class, space = SPACE),
                 @Part(value = FraudFinding.class, space = SPACE),
                 @Part(value = CoverageFinding.class, space = SPACE),
                 @Part(value = Instance.class, space = SPACE, keyTag = "claimId")})

The OrderedAssembler names the payments space as the join's own space, because example 18's ordered log runs there, and names intake on every part, because the findings live there. The join's space is where its tickets go; its parts may live anywhere.

// agentspaces-agent tests -- AgentBinderJoinTest.LeasedJoiner.assemble: two joiners sharing one ticket under a lease
@SpaceJoin(space = "intake", key = "claimId", mode = SpaceJoin.Mode.LEASED,
        takeLease = "300ms", pollTimeout = "100ms",
        parts = {@Part(Fraud.class), @Part(Coverage.class)})
public Assessment assemble(Joined j) {
    invocations.incrementAndGet();
    if (crashesLeft.getAndDecrement() > 0) {
        throw new IllegalStateException("simulated crash");
    }
    return new Assessment(j.key(), j.get(Fraud.class).score(), j.get(Coverage.class).covered(), 0, "");
}

The test binds two of these joiners over one space. Both see each key complete, one ticket stands per key, and the joiner that takes it fires. When the firing throws, the ticket's take lease lapses and the other joiner takes it, which is the take retry applied to a join.

Two stories, told as they happenedThe first version of the Assembler was hand-written, and it guarded against firing twice by reading for its own ClaimAssessment. The adjuster's auction take consumed that assessment within milliseconds, and a consumed entry is invisible to a read, so the assembler fired again and the adjusters priced one claim twice. The second story is the outrun part. In the LEASED and ORDERED modes, the peer that saw a claim complete writes the ticket, and the peer that wins the ticket may not hold every part of that claim yet. A stress run of the ordered join under CPU load lost two claims in sixty that way, before the binder learned to wait for the parts and, if they never arrive, to release the ticket by its lease instead of completing it. Both are now the binder's job; the guide tells them so you recognise their shape in your own flows.
How to choose the join modeStart from what a duplicate assessment would cost. If the next stage keys on the claim id and a duplicate only wastes work, LOCAL on one peer, or on several, is enough. If a duplicate is costly but a rare one under a partition is survivable, LEASED gives you several joiners with one ticket per claim and no quorum. If the assessment starts an irreversible step, ORDERED gives you once across the group, and you accept that the join needs the quorum to be live and must sit on the space that hosts the coordinator.

Gather: several of one kind

What the stage must do

Wait for the three RepairQuotes of one claim and then choose the lowest. The QuoteDesk in chapter 3 wrote a QuoteRequest saying how many quotes to expect, so the gatherer can wait for exactly that many.

How AgentSpaces does it

// agentspaces/examples/example-18-agentic-workflow -- Quotes.QuoteGatherer.pick
@SpaceJoin(space = Intake.SPACE, key = "claimId", resultSpace = Settlement.LEDGER, resultLease = "24h",
        parts = {@Part(QuoteRequest.class),
                 @Part(value = RepairQuote.class, countedBy = "QuoteRequest.expected")})
public RepairEstimate pick(Joined j) {
    List<RepairQuote> quotes = j.all(RepairQuote.class);
    RepairQuote lowest = quotes.stream().min(Comparator.comparingDouble(RepairQuote::amount)).orElseThrow();
    return new RepairEstimate(j.key(), lowest.shop(), lowest.amount(), quotes.size());
}

A gather is a join whose part may appear several times for one key. countedBy = "QuoteRequest.expected" tells the binder to read the number of RepairQuotes to wait for from the expected field of the QuoteRequest part of the same claim, whenever that request lands, before or after the quotes. When the quotes reach that count, the binder calls pick, and Joined.all(RepairQuote.class) returns every quote for the claim. The method body is the fold: it keeps the lowest. The binder writes the RepairEstimate into the ledger space.

The options: when is "enough"

Option 1: a count another record declares. countedBy = "QuoteRequest.expected", as the QuoteGatherer uses. Choose it when the stage that forked the work knows how many pieces it made, and have that stage write the count; the binder reads it whenever the counting record lands.

Option 2: a fixed count. atLeast = n on the part, as the test gatherer below uses. Choose it when the design fixes the number. A fourth quote after the join fired does not fire it again.

Option 3: quiet for a while. settle = "300ms" on the join with atLeast = 1 on the part, as the second test gatherer below uses. The binder fires once no new quote has arrived for the settle time. Choose it when nobody knows the count; a slow bidder extends the window, and a settle time shorter than the gap between two honest bids splits one gather into two.

Option 4: give up. within on the join, one hour by default. The binder forgets a claim whose parts stop arriving, and a later part starts the claim again. Set it on every gather as the deadline for a claim that will never complete.

The options at a glance

OptionAgentSpaces APIExample 18Choose it whenAlternative shown below
A count another record declares@Part(countedBy = "Type.field")the QuoteGatherer above: the QuoteRequest declares threethe stage that forked the work knows how many pieces it madeabove
A fixed count@Part(atLeast = n)not usedthe design fixes the number: always two signatures, always three bidsAgentBinderGatherTest.AtLeastTwo
Quiet for a whilesettle = "2s" on @SpaceJoin, usually with atLeast = 1not usednobody knows the count: collect bids until no new bid has arrived for the settle time; a slow bidder extends the windowAgentBinderGatherTest.Settled
Give upwithin = "1h" on @SpaceJoinevery join in example 18, at the defaultalways, as the deadline for a claim whose parts never all arrive; a part that arrives later starts the claim againabove
// agentspaces-agent tests -- AgentBinderGatherTest.AtLeastTwo.pick: a fixed count
@SpaceJoin(space = "quotes", key = "claimId", parts = {@Part(value = Quote.class, atLeast = 2)})
public Best pick(Joined j) {
    fired.add(j);
    Quote best = j.all(Quote.class).stream().min((a, b) -> Double.compare(a.amount(), b.amount())).orElseThrow();
    return new Best(j.key(), best.shop(), best.amount(), j.all(Quote.class).size());
}
// agentspaces-agent tests -- AgentBinderGatherTest.Settled.pick: whatever arrived before the key went quiet
@SpaceJoin(space = "quotes", key = "claimId", settle = "300ms",
        parts = {@Part(value = Quote.class, atLeast = 1)})
public Best pick(Joined j) {
    fired.add(j);
    Quote best = j.all(Quote.class).stream().min((a, b) -> Double.compare(a.amount(), b.amount())).orElseThrow();
    return new Best(j.key(), best.shop(), best.amount(), j.all(Quote.class).size());
}

Reduce: three forms, and which one you mean

How to choose the right reduceAsk what "many" means. If it means a bounded set of records about one case, the lowest of three quotes, you want the gather: the records wait in the space and the join fires once with all of them. If it means an unbounded stream of records about one case that must stay current after each one, a running balance, you want @SpaceReduce: the accumulator is an entry that every peer can read. If it means one number per peer, or a numeric field over whatever each peer holds, and an approximation that settles is acceptable, you want the aggregate: no peer collects anything, and the figure converges by gossip.

"Reduce" names three different things in a fleet, and AgentSpaces has a separate expression for each. The QuoteGatherer above is the first: a bounded set of records per key, folded once in the method body. The Exposure stage below is the second: an unbounded stream of records per key, folded one at a time into an accumulator that stays current. The ReserveSensor and Supervisor in chapter 9 are the third: one number per peer, averaged across the group.

The gather (@SpaceJoin)The persistent accumulator (@SpaceReduce)The aggregate (Contribution, @OnEstimate)
The elementsa bounded set of records per keyan unbounded stream of records per keyone value per peer, or the matches of a template on each peer
The operatorany Java code in the methodany Java code in the methodsum, average, count, min, max, quantile
Where the state livesin the element entries, until the join firesin an accumulator entry beside the elementsinside the aggregate capability on each peer
When the value is currentonce, when the join firesafter every elementan estimate that converges and then settles
Exactyesyesconverging
Survives a peer restartyes, in the entriesyes, in the accumulator entryno; the epoch forms again
Who can read itanyone can read the elementsanyone can read the accumulator, in any languagea Java peer calls estimate
Example 18the QuoteGathererthe Exposure stagethe ReserveSensor and Supervisor

What the Exposure stage must do

Keep one ClaimantExposure record per claimant, holding the total paid and the number of payments, and advance it each time a Payment lands, without ever counting a payment twice and without two peers losing each other's update.

// agentspaces/examples/example-18-agentic-workflow -- Settlement.Exposure.expose
@SpaceReduce(space = PAYMENTS, key = "claimant", name = "exposure", lease = "30s", pollTimeout = "300ms",
        accumulatorLease = "24h")
public ClaimantExposure expose(ClaimantExposure exposure, Payment payment) {
    long paid = (exposure == null ? 0 : exposure.paidCents()) + payment.cents();
    int count = (exposure == null ? 0 : exposure.payments()) + 1;
    return new ClaimantExposure(payment.claimant(), paid, count);
}

@SpaceReduce tells the binder that expose is a fold. The first parameter type, ClaimantExposure, is the accumulator; the second, Payment, is the element; key = "claimant" names the element field that groups payments by claimant. For each payment the binder takes the Payment entry under a thirty-second lease, reads the claimant's current ClaimantExposure entry (null for the first payment), calls expose, and completes the Payment entry with the returned ClaimantExposure as the result, in one commit. That one commit is why the stage never counts a payment twice: either the commit consumes the payment and writes the new total, or it does neither. The binder then retires the previous total. The twenty-four-hour accumulatorLease is the write lease of each new total, so a claimant with no payment for a day lapses from the space, and the lapse is an event chapter 10 can react to.

Two reducers on two peers could each take a different payment of the same claimant at the same moment, read the same old total, and each write a new total that ignores the other's payment. The binder prevents that with a JoinTicket per claimant: a reducer takes the claimant's ticket before it folds, renews the ticket's lease before every payment, and stops if a renewal fails. The hand-written version of this stage, take the payment, read the total, write a new one, loses updates in exactly that race, and no test on one peer shows it.

Reductions.current(space, ClaimantExposure.class, "exposure", claimant) is how any stage reads a claimant's newest total, and it is what the test uses on every peer. The developer guide's annotation chapter lists every attribute of @SpaceReduce, and the PetClinic scheduler keeps its appointment slot book the same way, as the other consumer.

aspace://workflow/compose

5. Compose

Where we are in the claims flow

Stage 3, inside the Assembler. The four parts of a claim are in hand, and the assembler must write one ClaimAssessment that the adjusters can price without reading the four parts again.

The records in play

Record (the claims flow's POJO)What it means in the businessWho writes itWho reads itSpace
ClaimAssessmentthe claim id, the claimant, the amount claimed, the damage estimate and severity from the damage finding, the fraud score, whether the policy covers the claim, the coverage limit, and who assembled itthe Assemblerthe adjusters, the quote desk, the senior reviewerassessment

What the stage must do

Copy into the assessment exactly what the next stages need, and nothing else, so that the adjusters can price it, the quote desk can fork from it, and the senior reviewer can filter on its severity, each with one read of one record.

// agentspaces/examples/example-18-agentic-workflow -- Intake.Assembler.assemble: the composition
    RegisteredClaim claim = j.get(RegisteredClaim.class);
    Instance damage = j.get(Instance.class);
    return new ClaimAssessment(j.key(), claim.claimant(), claim.claimedAmount(),
            damage.number("estimate"), damage.string("severity"),
            j.get(FraudFinding.class).fraudScore(), j.get(CoverageFinding.class).covered(),
            j.get(CoverageFinding.class).coverageLimit(), name);

The Joined bag that the binder hands to assemble answers get(type) for the newest part of one type, find(type) for a part marked optional, all(type) for a gathered part, and entry(type) for the entry's metadata: who wrote it, its tags, its lease, and when. The assembler copies values out of the parts into the new record. damage.number("estimate") and damage.string("severity") read properties of the ontology instance, which chapter 12 explains.

The options: how to carry each value

Option 1: copy the value into the composed record. The assembler copies the severity, the fraud score, and the coverage limit into the ClaimAssessment, because the senior reviewer filters on the severity, the adjusters bid on it, and the pricing reads the limit. Choose it for any value a later stage matches on, keys on, or branches on; the writer owns the duplication, and the value freezes at composition time.

Option 2: carry the id of another entry. Every claim record carries policyId as a string rather than a policy. A stage that needs the policy reads it with a template on the id, as the Panelist in chapter 7 reads the PricedClaim by claimId. Choose it when the referent is large, has its own lease, or changes on its own; the reading stage pays one read and must handle a referent that lapsed.

Option 3: write a label as a tag. Return Tagged.of(record, key, value, ...), as the DamageEstimator does in chapter 12. Choose it when a stage must select on the value before decoding the record, or when the value is a label such as a class IRI rather than data. Tags stay in plain text under content encryption.

Option 4: compose parts from several spaces. Name a space on each @Part that lives elsewhere, as the OrderedAssembler in chapter 4 and the Party Bus plan assembler below do. Choose it when the parts live in different lanes; the join's own space is where its tickets go.

The options at a glance

OptionAgentSpaces APIExample 18Choose it whenAlternative shown below
Copy the value into the composed recorda record componentseverity, fraud score, coverage limit: every later stage filters or decides on thema later stage will match on the value with where, key a join on it, or branch on itabove
Carry the id of another entrya String id component, read later with Template.of(T.class).where("id", eq(...))policyId on every claim record; the Panelist reads the PricedClaim by claimId (chapter 7)the referent is large, has its own lease, or changes on its ownchapter 7, the Panelist
Write a label as a tagreturn Tagged.of(record, key, value, ...)the DamageEstimator tags its instance with the claim id and the class IRI (chapter 12)a stage must select on the value before decoding, or the value is a label rather than datachapter 12
Compose parts from several spaces@Part(value = T.class, space = "...")the OrderedAssembler (chapter 4)the parts live in different lanesParty Bus PlanAssembler
// agentspaces-partybus -- Assemblers.PlanAssembler.assemble: parts from two spaces, one of them optional
@SpaceJoin(space = Party.TRIP, key = "tripId", resultLease = "12h",
        parts = {@Part(TravelBrief.class),
                 @Part(value = SightseeingOptions.class, space = Scouts.SPACE),
                 @Part(value = HotelOptions.class, space = Scouts.SPACE),
                 @Part(value = RestaurantOptions.class, space = Scouts.SPACE),
                 @Part(value = FoodieOptions.class, space = Scouts.SPACE),
                 @Part(value = FlightOptions.class, space = Scouts.SPACE),
                 @Part(value = Travelers.class, optional = true)})
public ProposedTravelPlan assemble(Joined j) {
    TravelBrief brief = j.get(TravelBrief.class);
    Travelers party = j.find(Travelers.class).orElse(new Travelers(j.key(), List.of()));

The Party Bus plan assembler composes a ProposedTravelPlan from a TravelBrief in the trip space and five research results in the scouts space, and treats the Travelers record as optional: j.find(Travelers.class) returns an Optional, and the method supplies an empty party when nobody wrote one.

aspace://workflow/route

6. Filter, route, and branch

Where we are in the claims flow

Stages 3a, 3b, 4, and 1 again. The QuoteDesk wrote three QuoteTasks, and each of the three shops must take only the task that names it. The SeniorReviewer must see only the assessments whose severity is HIGH. The two adjusters must split the assessments so that the senior adjuster prices the severe ones and the junior the routine ones. And the Registrar of chapter 2 must send a claim down one of two paths, registered or rejected.

The records in play

Record (the claims flow's POJO)What it means in the businessWho writes itWho reads itSpace
QuoteTaska quote task that names one shop in its shop field: north, east, or souththe QuoteDeskthe shop it namesintake
RepairQuotea shop's quotethe shopthe QuoteGathererintake
ClaimAssessmentthe assessment, with a severity field of LOW, MEDIUM, or HIGHthe Assemblerthe adjusters and the SeniorReviewerassessment
SeniorReviewa note on a severe claimthe SeniorReviewerthe ledger's readersledger
PricedClaimthe reserve and the recommendation, and which adjuster priced itan adjusterthe underwriter, the panel, the approverassessment
Registration, RegisteredClaim, Rejectedthe registrar's two outcomes behind one sealed interfacethe Registrarthe specialists; the Ledgerintake

Filter: a take or a reaction that sees only some entries

What the stage must do

The NorthShop must take QuoteTask entries whose shop field is north and never touch the others. The SeniorReviewer must react to ClaimAssessment entries whose severity field is HIGH and ignore the rest.

How AgentSpaces does it

Every take and every reaction matches entries against a template. A template is a query: one record type, plus conditions on the record's fields and on the entry's tags. The binder builds the template for a method from the method's parameter type, and the annotation's where and tags attributes add conditions to it. The replica applies the template before it hands the entry to anyone, so a condition on the annotation keeps the method from ever seeing an entry that fails it.

// agentspaces/examples/example-18-agentic-workflow -- Quotes.NorthShop.quote: a take that sees only its own tasks
@SpaceTake(space = Intake.SPACE, where = "shop=north", lease = "30s", pollTimeout = "300ms",
        resultLease = "1h")
public RepairQuote quote(QuoteTask task) {
    return Quotes.quote(task, "north", 1.1);
}

where = "shop=north" adds the condition "the shop field equals north" to the NorthShop's template. The replica judges that condition before the take claims anything, so the north shop never leases a task meant for the east shop, and that task stays in the space for the east shop's take. Example 18 has one class per shop, NorthShop, EastShop, and SouthShop, because a Java annotation value must be a constant, and it binds one shop per peer.

// agentspaces/examples/example-18-agentic-workflow -- Quotes.SeniorReviewer.review: a reaction that sees only severe assessments
@SpaceNotify(space = Pricing.SPACE, where = "severity=HIGH", resultSpace = Settlement.LEDGER,
        resultLease = "24h")
public SeniorReview review(ClaimAssessment assessment) {
    return new SeniorReview(assessment.claimId(), "severe: damage " + assessment.damageEstimate()
            + " against a limit of " + assessment.coverageLimit());
}

The same attribute on a reaction keeps the replica from delivering a LOW or MEDIUM assessment to review at all. The test asserts that exactly one SeniorReview exists, for the one severe claim.

The order in which a replica judges a template matters. It compares the record type first, then the tags, then whether the entry is already claimed, then it decodes the record and judges the field conditions. Tags therefore cost nothing to check and a relay that cannot decrypt the record can still route on them (chapter 12); field conditions need the decoded record, so only a peer that holds the content key can judge them.

The options

Option 1: filter on a field, on the annotation. where = "shop=north" or where = "severity!=LOW". The binder adds the condition to the method's template at bind time and refuses a field the record lacks. Choose it when the routing key is a field with a fixed set of values; the filter compares the field's string form for equality or inequality, and a range stays in the method.

Option 2: filter on a tag, on the annotation. tags = "rdf:type=..." or tags = "region". The replica judges a tag before it decodes the record, so a relay without the content key can route on it, and a shard can hold only entries with it. Choose it when the producer labels the entry, as the DamageEstimator labels its instance in chapter 12.

Option 3: filter in the API, with any matcher. Build the template yourself: Template.of(PricedClaim.class).where("claimId", eq(id)), or gte, lt, in, contains. Choose it when the stage reads rather than subscribes, as the Panelist does in chapter 7, or when the condition is a comparison the annotation grammar cannot express.

Option 4: decide inside the method. Return null, as the PetClinic Vet does in chapter 3. Choose it when the decision needs a lookup or a computation. On a take, the binder has already claimed the entry by then and completes it when the method returns null, so the entry is gone; on a reaction the binder consumes nothing.

The options at a glance

OptionAgentSpaces APIExample 18Choose it whenAlternative shown below
Filter on a field, on the annotationwhere = {"field=value", "field!=value"} on @SpaceTake, @SpaceNotify, @OrderedTake, @Part, and @Proposethe shops and the SeniorReviewer abovethe routing key is a field with a fixed set of values; equality and inequality on the field's string formAgentBinderVerbsTest.NotLow
Filter on a tag, on the annotationtags = {"key=value", "key"} on the same annotationsthe Assembler's damage part: tags = "rdf:type=..." (chapter 4)the producer labels the entry, and a relay or a shard may need to route on the label without decodingchapter 4
Filter in the API, with any matcherTemplate.of(T.class).where("field", gte(3)) and the other Matchersthe Panelist reads a PricedClaim by claimId (chapter 7)the condition is a comparison, a set, or a substring; or the stage reads rather than subscribeschapter 7
Decide inside the methodreturn nullthe Escalator (chapter 10); the PetClinic Vet (chapter 3)the decision needs a lookup or a computation; a take has already claimed the entry by then, and completes itchapter 3
// agentspaces-agent tests -- AgentBinderVerbsTest.NotLow.see: an inequality filter
@SpaceNotify(space = "tasks", where = "severity!=LOW")
public void see(Brief brief) {
    seen.add(brief.id());
}
When to use tags, and when to use whereA field lives inside the record and a tag lives beside it on the entry. Use where when the value is part of the data and only a peer with the content key should read it: the shop a task names, the severity of an assessment. Use tags when the value is a label the producer attaches so that others can route without reading the data: the class of an ontology instance, the region a shard holds, a sensitivity level the console shows. A replica judges tags before decoding and fields after, so tags are cheaper and more visible, and that visibility is the trade-off: a tag is plain text to every relay.

Branch: one method, two possible outcomes

What the stage must do

The Registrar must send each claim down one of two paths. A registered claim goes to the specialists; a rejected claim goes to the ledger. A Java method has one return type.

// agentspaces/examples/example-18-agentic-workflow -- Claims.Registration: the two outcomes behind one type
/** Stage 1: what the registrar returns, one of two branches; the card declares both. */
public sealed interface Registration permits RegisteredClaim, Rejected {
}

The Registrar.register method of chapter 2 declares Registration as its return type and returns either a RegisteredClaim or a Rejected. The binder writes whichever concrete record the method returns, and because you sealed the interface, the binder lists both permitted records on the agent's card, so a planner reading the card knows the registrar may produce either. The specialists consume RegisteredClaim; the Ledger consumes Rejected; each branch has its own consumer, and the flow never loses a claim it refused.

// agentspaces-agent tests -- AgentBinderVerbsTest.Gate.decide: a sealed return from a take
public sealed interface Next permits Accepted, Declined {
}

@SpaceTake(space = "tasks", pollTimeout = "50ms")
public Next decide(Brief brief) {
    return brief.severity().equals("HIGH") ? new Declined(brief.id(), "too risky") : new Accepted(brief.id());
}

Route by cost: let the space choose the worker

What the stage must do

Two adjusters price assessments. The SeniorAdjuster should price the severe claims and the JuniorAdjuster the routine ones, but neither should sit idle while the other has a backlog. The right routing depends on who is cheapest for each assessment at that moment, which no filter on the annotation can express.

How AgentSpaces does it

Chapter 1 built the assessment space with the AUCTION conflict strategy. Under that strategy every take carries a bid, the space collects the bids across the group for one gossip round, and the lowest bid wins the entry. An agent supplies its bid with a @BidFunction method, which the binder calls for each candidate entry before the take.

// agentspaces/examples/example-18-agentic-workflow -- Pricing.SeniorAdjuster: the bid and the take
@BidFunction(space = SPACE)
public double bid(ClaimAssessment a) {
    return "HIGH".equals(a.severity()) ? 10 : 80;
}

@SpaceTake(space = SPACE, lease = "30s", pollTimeout = "300ms", resultLease = "1h")
public PricedClaim price(ClaimAssessment a) {
    return Pricing.price(a, "senior-adjuster");
}

The SeniorAdjuster bids 10 for a HIGH assessment and 80 otherwise; the JuniorAdjuster bids 90 and 20. The test asserts that the senior adjuster priced the fire claim and the junior the collision. The pricing itself, Pricing.price, is the same code in both; only the bid differs. One agent per space per peer may bid, and every bid in a space should use one comparable cost unit.

The options, side by side

The three routing shapes answer three different questions. A filter asks which of the entries of this type are mine, and the answer is in the entry. A branch asks which type this entry should become, and the answer is in the producer. The auction asks which of several willing workers should take this entry, and the answer depends on the workers and the moment. The table collects them with the fork of chapter 3, which is the fourth shape: all of them at once.

The options at a glance

OptionAgentSpaces APIExample 18Choose it whenAlternative shown below
Route by a field valuewherethe shops, the senior reviewerthe routing key is in the record and its values are known when you write the classabove
Route by a labeltagsthe assembler's damage partthe producer labels entries and consumers must not decode to routechapter 4
Route by costthe AUCTION strategy on the space and a @BidFunctionthe adjustersworkers differ by price, load, skill, or locality, and the cheapest should winabove
Branch at the producera sealed return typethe registrarone stage chooses among several next recordsabove
Branch to several records at onceEntries.of(...)the quote deskthe outcomes are not alternatives but companionschapter 3
A template names one concrete record typeA @SpaceNotify whose parameter is the Registration interface matches nothing on a replicated space, because a replica matches the entry's type name exactly and an entry is always a concrete record. Model a hierarchy as one record per leaf, as example 18 does, or as one record with a discriminator field you filter on.
aspace://workflow/decide

7. Decide

Where we are in the claims flow

Stages 5 and 6. An adjuster has written a PricedClaim. The department must put every priced claim to a panel of three, one panelist per peer, and act on the panel's decision: a PaymentOrder when the panel approves, a ClaimDenied when it denies.

The records in play

Record (the claims flow's POJO)What it means in the businessWho writes itWho reads itSpace
PricedClaimthe reserve and the adjuster's recommendationan adjusterthe Underwriter, the Panelists, the Approverassessment
Proposal, Ballot, Decisionthe vote capability's own records: the question with its options and quorum; one voter's answer; the closed result with its tallythe binder, on the agents' behalfthe binderdecisions
Reminderthe panel's deadline for one claim (chapter 10)the Underwriterthe Escalatordecisions
PaymentOrderan approved claim, as an order to paythe Approverthe Payerpayments
ClaimDenieda denied claim and the tallythe Approverthe Ledgerledger

What the stages must do

The Underwriter opens one vote per priced claim, with the options approve and deny and a quorum of three. Each Panelist casts one ballot per vote, judging the priced claim against a ceiling of its own. The Approver acts exactly once when a vote closes.

How AgentSpaces does it

The vote capability keeps proposals, ballots, and decisions as ordinary entries in the decisions space, each signed by the agent that wrote it, so the space itself replicates the vote, authenticates every ballot, and keeps the audit trail. Three annotations describe one vote across three classes.

// agentspaces/examples/example-18-agentic-workflow -- Decisions.Underwriter.open: opening the vote
@Propose(space = Pricing.SPACE, vote = SPACE, prefix = PREFIX, key = "claimId",
        options = {Pricing.APPROVE, Pricing.DENY}, quorum = 3, lease = "10m")
public String open(PricedClaim priced) {
    // The deadline is a leased entry: when it lapses, the escalator below
    // reacts; when the panel decides first, the escalator finds the
    // decision and does nothing.
    decisions.write(new Reminder(priced.claimId()), reminderLease);
    return "Settle claim " + priced.claimId() + "? The adjuster recommends "
            + priced.recommendation() + " with a reserve of " + priced.reserve();
}

@Propose tells the binder that open opens a vote. The binder reacts to every PricedClaim in the assessment space, builds the proposal id from prefix plus the claim's claimId field, calls open for the question text, and writes a Proposal entry into the decisions space with the options, the quorum, and the ten-minute lease from the annotation. The binder opens each proposal id once per peer, and the method may return null to open nothing. The decisions.write line writes the Reminder that chapter 10 uses as the deadline, through a @SpaceRef field.

// agentspaces/examples/example-18-agentic-workflow -- Decisions.Panelist.judge: one ballot per proposal
@Ballot(space = SPACE, prefix = PREFIX, lease = "10m")
public String judge(VoteCapability.Proposal proposal) {
    Optional<PricedClaim> priced = assessments.read(Template.of(PricedClaim.class)
            .where("claimId", eq(claimIdOf(proposal.proposalId()))), Duration.ofSeconds(5));
    if (priced.isEmpty()) {
        return null;                       // abstain
    }
    boolean approve = Pricing.APPROVE.equals(priced.get().recommendation())
            && priced.get().reserve() <= ceiling;
    return approve ? Pricing.APPROVE : Pricing.DENY;
}

@Ballot tells the binder that judge casts ballots. The binder reacts to every Proposal whose id starts with claim:, calls judge, and writes a Ballot entry with the returned option, signed by the panelist's agent identity. The panelist reads the PricedClaim it is judging through a @SpaceRef field with a five-second wait, because the priced claim may not have replicated to this peer yet, and returns null to abstain if it has not. Example 18 gives the three panelists ceilings of 50,000, 50,000, and 15,000, so the panel splits two to one on the 25,000 fire claim and still approves it.

// agentspaces/examples/example-18-agentic-workflow -- Decisions.Approver.settle: acting once on the decision
@OnDecision(space = SPACE, prefix = PREFIX, resultSpace = Settlement.PAYMENTS, resultLease = "1h")
public PaymentOrder settle(VoteCapability.Decision decision) {
    String claimId = claimIdOf(decision.proposalId());
    PricedClaim priced = assessments.read(Template.of(PricedClaim.class)
            .where("claimId", eq(claimId)), Duration.ofSeconds(5)).orElseThrow();
    if (!Pricing.APPROVE.equals(decision.winner())) {
        ledger.write(new ClaimDenied(claimId, "the panel voted " + decision.tally()
                + ", counted per " + decision.granularity()), Lease.of(Duration.ofHours(24)));
        return null;
    }
    return new PaymentOrder(claimId, priced.claimant(), Math.round(priced.reserve() * 100));
}

@OnDecision tells the binder that settle reacts to closed votes. When this peer's tally of ballots for a proposal reaches the quorum, the binder calls settle once with the Decision, which carries the winner, the tally, and the granularity the voters were counted at. The approver writes a PaymentOrder into the payments space by returning it, or a ClaimDenied into the ledger through a @SpaceRef field, since one return carries one record. Example 18 binds one Approver, on peer-a.

The options

Option 1: open with a fixed shape. @Propose holds the options, the quorum, the lease, and the rule that builds the proposal id, and the method returns only the question text. The Underwriter uses it because every claim vote looks the same. The Party Bus safety lead below uses it with a composite key, three fields joined into one id, so that several assessments of one wave open one vote.

Option 2: open with a shape built per call. Return a Motion from any bound method, built with Motion.of(id, question, options, quorum, lease). The Escalator of chapter 10 does this, and the Party Bus party lead below does it because its options are the destinations a scout proposed, which differ per trip. The binder opens a Motion's proposal id once, so a reaction that runs on every peer may return one safely.

Option 3: abstain. Return null from the @Ballot method. The Panelist abstains when the PricedClaim has not replicated within five seconds. An abstention is final for that voter on that proposal, so read with a timeout before abstaining.

Option 4: choose the quorum. The quorum is the number of distinct voters that closes the decision: three for the panel, one for the escalation. Too low and one peer decides; too high and a lost peer stalls the vote until the deadline of chapter 10 escalates it.

Option 5: choose who may vote. By default every authenticated agent in the group may cast a ballot. The vote capability can count only the holders of fresh cards, or only the agents an authorizer grants; the developer guide's security chapter covers both. A stage that needs the tally or the decision as data rather than as a cue injects a VoteClient with @CapabilityRef and asks it; a peer that serves the vote to the group registers it, through the facade or with @ProvidesCapability on the bean that builds it.

The options at a glance

OptionAgentSpaces APIExample 18Choose it whenAlternative shown below
Open with a fixed shape@Propose: options, quorum, lease, and the id rule on the annotation; the method returns the questionthe Underwriterevery vote of this kind has the same options and quorum, and the cue decides only the questionParty Bus SafetyPanel, with a composite key
Open with a shape built per callreturn a Motion from any bound method: Motion.of(id, question, options, quorum, lease)the Escalator (chapter 10)the options or the quorum come from the cue, or the stage is not a reaction on the cue typeParty Bus Party.open
Abstainreturn null from a @Ballot methodthe Panelist when the priced claim has not replicateda judge lacks the evidence; read with a timeout first, because an abstention is final for that voterabove
Choose the quorumquorum on @Propose or the Motion3 for the panel; 1 for the escalationas many distinct voters as the decision needs; too low and one peer decides, too high and a lost peer stalls the vote until a deadline escalates itchapter 10
Choose who may votethe vote capability's electorate: every authenticated agent, the holders of fresh cards, or an authorizer's grantsthe default: every authenticated agent in the groupthe panel must be a named set; see the developer guide's security chapter
Propose or MotionIf you can write the options and the quorum as annotation constants, use @Propose: the shape is visible on the class, the binder derives the id, and the method stays a one-line question. Reach for a Motion when the options come from the cue, when the id must follow a rule the annotation cannot express, or when the stage that opens the vote is a deadline reaction or a settled-estimate reaction rather than a reaction on the cue type. Both open once per proposal id.
// agentspaces-partybus -- SafetyPanel.open: a composite key, one vote per trip, location, and wave
@Propose(space = Watchers.SIGNALS, vote = ADVISORIES, prefix = PROPOSAL_PREFIX,
        key = {"tripId", "location", "wave"}, options = {"GO", "CAUTION", "AVOID"}, quorum = 3,
        lease = "12h", subscriptionLease = "12h")
public String open(DangerAssessment assessment) {
    waves.putIfAbsent(Safety.proposalId(assessment.tripId(), assessment.location(),
            assessment.wave()), assessment);
    return "Travel advisory for " + assessment.location() + " (" + assessment.wave() + ")";
}

The Party Bus safety lead reacts to every DangerAssessment, and several assessments, one per watcher channel, describe the same trip, location, and wave. The composite key joins the three fields into one proposal id, so the binder opens one vote per wave however many assessments arrive.

// agentspaces-partybus -- Party.PartyLead.open: a Motion whose options come from the cue
@SpaceNotify(space = Scouts.SPACE, lease = "12h")
public Motion open(NextDestinationIdeas ideas) {
    String proposalId = "next:" + ideas.tripId();
    List<String> options = new ArrayList<>();
    for (Destination idea : ideas.ideas()) {
        options.add(idea.name());
    }

The party lead's options are the destinations a scout proposed, which differ per trip, so no annotation can hold them. The method builds a Motion with the options from the cue and returns it, and the binder opens the vote once per proposal id.

aspace://workflow/exactly-once

8. Exactly once

Where we are in the claims flow

Stage 7. The Approver wrote a PaymentOrder. A Payer on each of the three peers stands ready to pay it. The department must pay each order exactly once, and a network partition must not turn one order into two payments.

The records in play

Record (the claims flow's POJO)What it means in the businessWho writes itWho reads itSpace
PaymentOrderan approved claim, as an order to pay the claimant a number of centsthe Approverone Payerpayments
Paymentthe payment made: who was paid, how much, by which peer, and the log index that admitted itthe Payer that won the orderthe Ledger, the Exposure stagepayments

What the stage must do

Take each PaymentOrder at most once across all three peers, pay it, and write the Payment in the same commit as the completion, so that neither a crash nor a partition produces two payments or zero.

How AgentSpaces does it

The lease race that the other spaces use promises at most one completed take per entry, but it allows two peers to both believe they hold an entry during a partition, and for a payment that is one belief too many. The ordered-log capability elects a Raft quorum among the three peers and serializes takes through a log: a take becomes a claim the log commits, the log admits one claim per entry generation, and every peer applies the same committed order, so every peer agrees on who holds each order.

// agentspaces/examples/example-18-agentic-workflow -- Settlement.Payer.pay
@OrderedTake(space = PAYMENTS, lease = "30s", pollTimeout = "2s", resultLease = "1h")
public Payment pay(PaymentOrder order) {
    return new Payment(order.claimId(), order.claimant(), order.cents(), name,
            ordered.raft().commitIndex());
}

@OrderedTake is @SpaceTake with the claim routed through the coordinator. The binder runs the same loop as chapter 2, but each claim goes through the log, and the binder resubmits a claim that a leader election lost. When the log admits the claim, the binder calls pay, then completes the PaymentOrder and writes the Payment in one commit, in the payments space. The ordered field is the coordinator the peer registered, which the payer uses only to record the commit index in the payment.

// agentspaces/examples/example-18-agentic-workflow -- ClaimsFlow.startPeer: registering the three capabilities
group.provide(vote);                          // backs @Ballot and @OnDecision on "decisions"
group.provide(aggregate);                     // the target of Contribution returns
group.ordered(Settlement.PAYMENTS, ordered);  // backs @OrderedTake on "payments"

group.ordered(space, coordinator) tells the binder which coordinator backs @OrderedTake methods on the payments space. A peer hosts one ordered log per group, which is why the OrderedAssembler of chapter 4 also lives on the payments space. The log does not transact with the world outside the space: the method may still call a payment gateway once per retry, so the method makes the irreversible call last and keys it by the order's id.

The options, as a ladder

Rung 1: an idempotent write. If the step is a record and a second copy costs nothing, give the record a deterministic id and let a duplicate replace the first. The Ledger could write a ClaimClosed twice without harm.

Rung 2: a take under the lease race. @SpaceTake on an ordinary space promises at most one completed take per entry and tolerates transient duplicate work under a partition. The Registrar and the shops stand here, because registering or quoting twice is harmless.

Rung 3: a take through the ordered log. @OrderedTake promises exactly one completed take across the group, and costs a quorum round trip per claim and a live quorum. The Payer stands here, and example 12's Clerk below is the same stage on its own.

Rung 4: a join through the ordered log. @SpaceJoin(mode = ORDERED) gives the join's firing the same promise, for a join whose output starts an irreversible step, as the OrderedAssembler of chapter 4 does.

The options at a glance

OptionAgentSpaces APIExample 18Choose it whenAlternative shown below
An idempotent writea record whose id is deterministic, so a second copy replaces the firstthe Ledger: a second ClaimClosed for one claim would be harmlessthe step is a record and a duplicate costs nothingchapter 10, the Ledger
A take under the lease race@SpaceTake on a LEASE_RACE spacethe Registrar, the shopsthe work is idempotent, or a duplicate under a partition is tolerablechapter 2
A take through the ordered log@OrderedTake, with group.ordered(...) registeredthe Payerthe step is irreversible and the group can keep a quorumexample 12's Clerk
A join through the ordered log@SpaceJoin(mode = ORDERED)the OrderedAssemblerthe join's output starts an irreversible stepchapter 4
// agentspaces/examples/example-12-exactly-once-desk -- ExactlyOnceDesk.Clerk.confirm: the same shape, as a standalone example
@OrderedTake(space = PAYMENTS, lease = "30s", pollTimeout = "2s", resultLease = "1h")
public PaymentReceipt confirm(PaymentOrder order) {
    return new PaymentReceipt(order.orderId(), order.payee(), order.cents(), peer.name(),
            peer.raft().commitIndex());
}
How far up the ladder to climbClimb one rung at a time and stop at the first that holds. Most stages stop at rung 2, because most agent work can repeat. Climb to rung 3 only for a step with an external, irreversible effect, and keep that effect at the end of the method, keyed by the entry's id, because the log serializes the claim on the entry and nothing else. Reach rung 4 only when the irreversible step is the join's own output.

Example 12 is this one stage on its own: three clerks, one log, orders written from several peers, and a test that kills a clerk mid-order and still counts one receipt per order.

aspace://workflow/aggregate

9. Aggregate, reduce, and sense

Where we are in the claims flow

Stage 8. Each adjuster has priced a claim and set a reserve. The department wants to know the average reserve across every claim the whole fleet has priced, without any peer collecting every claim, and it wants one report when that figure has settled.

The records in play

Record (the claims flow's POJO)What it means in the businessWho writes itWho reads itSpace
PricedClaima priced claim with its reservean adjusterthe ReserveSensor on every peerassessment
Contributionnot a record of the flow: the AgentSpaces return value that adds one number to a named aggregate epochthe ReserveSensor returns itthe aggregate capability
Estimatethe aggregate capability's record of the current fleet-wide figure for one epochthe capabilitythe Supervisor
ReserveReportthe settled average reserve, on the recordthe Supervisoranyoneledger

What the stage must do

Every peer contributes the reserve of every claim it sees to one shared figure, the fleet converges on the average with no collector, and one agent writes one ReserveReport when the average stops moving.

How AgentSpaces does it

The aggregate capability runs push-sum gossip: every peer holds a value and a weight, each gossip round it halves them and sends half to one random peer that advertises the aggregate, and every peer's ratio converges to the fleet-wide average in a number of rounds logarithmic in the fleet size. An agent adds to it by returning a Contribution, and reads it by reacting to the settled estimate with @OnEstimate, or procedurally through an AggregateClient injected with @CapabilityRef (awaitSettled). A settle rule counts steps, and a step is a gossip round the peer took part in once it has heard the epoch at all, or the epoch heard and since gone quiet, which completes the streak: a peer nobody pushes to never settles on its own value, and a plateau before a late contribution does not count as settled either.

// agentspaces/examples/example-18-agentic-workflow -- Settlement.ReserveSensor.sense and Settlement.Supervisor.report
@SpaceNotify(space = Pricing.SPACE)
public Contribution sense(PricedClaim priced) {
    return Contribution.to(RESERVE_EPOCH, priced.reserve());
}

@OnEstimate(prefix = "reserve:", settleTicks = 40, tolerance = 0.01, resultSpace = LEDGER,
        resultLease = "24h")
public ReserveReport report(PushSumAggregate.Estimate reserve) {
    return new ReserveReport(reserve.epochId(), reserve.value(), name);
}

The ReserveSensor reacts to every PricedClaim and returns Contribution.to(epoch, value); the binder hands the value to the aggregate capability on this peer under the epoch name reserve:2026-10. Example 18 binds one ReserveSensor on each of the three peers, and every peer's replica delivers every PricedClaim, so each reserve enters the aggregate three times. The AVG operator does not mind: the average of three copies of each reserve equals the average of the reserves, which is why this chapter's options steer a fully replicated space away from SUM and COUNT. @OnEstimate tells the binder that report reacts to a settled estimate: the capability evaluates the settle rule at the end of each of its own ticks, and when the estimate for an epoch whose name starts with reserve: has stayed within one percent for twenty consecutive ticks, the binder calls report once and writes the ReserveReport to the ledger. Nothing polls.

The settle window is the number to tune. Forty steps is about ten quiet seconds at example 18's 250-millisecond peer tick, longer than the gap between the first and the last priced claim, so the one report carries the average of all four; twenty was not, once the paid claim's reserve followed the ordered log and could land twenty seconds after the denials' zero reserves, and a twenty-step settle reported that plateau. A flow that prices claims over a longer window wants a longer settle, or one epoch per claim.

The options

Option 1: contribute a value per entry. Return Contribution.to(epoch, value) from any bound method, as the ReserveSensor does for every PricedClaim. Choose it when the figure is a property of the entries a stage already sees.

Option 2: contribute a template's matches. Call PushSumAggregate.start(epochId, mode, space, template, field) on each peer. The capability reads every local entry the template matches, applies the field extractor to each, and contributes the fold under the mode. Choose it when the figure is over what each peer already holds; the template filters, the field maps, the mode reduces.

Option 3: choose the operator. AVG is the default and the one to reach for over a fully replicated space, because every peer holds every entry and an average of copies is the same average. SUM and COUNT multiply by the number of peers over a replicated space, so they belong over sharded or per-peer spaces. MIN, MAX, and a Quantile do not mind copies.

Option 4: react once when it settles. @OnEstimate(prefix, settleTicks, tolerance), as the Supervisor uses. Choose it when one agent must act on the figure once.

Option 5: read the live figure. AggregateClient.estimate(epoch) returns the current estimate, and awaitSettled(epoch, settle, timeout) blocks until it settles. The test reads the figure on peer-c this way. Choose it for a dashboard or for procedural code.

Option 6: decide on the figure. Return a Motion from the @OnEstimate method. The settled figure then opens a vote: sense, then decide.

The options at a glance

OptionAgentSpaces APIExample 18Choose it whenAlternative shown below
Contribute a value per entryreturn Contribution.to(epoch, value) from any bound methodthe ReserveSensorthe figure is a property of entries the stage seesabove
Contribute a template's matchesPushSumAggregate.start(epochId, mode, space, template, field), called on each peernot usedthe figure is over what each peer already holds; the template filters, the field maps, the mode folds, in one call
Choose the operatorMode.AVG, SUM, COUNT, MIN, MAX, or a QuantileAVG, the defaultover a fully replicated space, SUM and COUNT multiply by the number of peers; AVG, MIN, MAX, and QUANTILE do not
React once when it settles@OnEstimate(prefix, settleTicks, tolerance)the Supervisorone agent must act on the figureabove
Read the live figureAggregateClient.estimate(epoch), or awaitSettled(epoch, settle, timeout) to blockthe test, to check the figure on peer-ca dashboard, or procedural codeexample 18's test
Decide on the figurereturn a Motion from the @OnEstimate methodnot usedthe settled figure should open a vote: sense, then decidechapter 7
Choosing a settle windowThe settle rule fires when the estimate has moved less than tolerance for settleTicks consecutive steps, a step being a gossip round this peer took part in once it has heard the epoch, or the epoch going quiet, which completes the streak. Make the window longer than the gap between the first and the last contribution you expect in one epoch, or the report carries a partial figure; example 18's first attempt at four ticks reported a three-claim average. Make it shorter than the time you can afford to wait after the last contribution. When contributions arrive over a long or unknown period, open one epoch per period, as the Spring AI usage sink does per model and time window, rather than widening the settle.
What a settle rule does not doA settle rule detects that the estimate stopped moving. It does not detect a poisoned contribution. When who may contribute matters, narrow the electorate of the vote the figure feeds, through cards or an authorizer, and read the developer guide's security chapter.
aspace://workflow/time

10. Time, loops, and compensation

Where we are in the claims flow

Stage 6a, and the ledger. The Underwriter opened a vote and the panel may never answer. The department must escalate a claim the panel has not decided within the deadline, once, however many peers notice. And the Registrar rejected a claim with no amount; the department must close that case on the record rather than lose it.

The records in play

Record (the claims flow's POJO)What it means in the businessWho writes itWho reads itSpace
Reminderthe deadline for one claim's decision, as an entry whose write lease is the deadlinethe Underwriter, when it opens the votethe Escalator, when the lease lapsesdecisions
Motionnot a record of the flow: the AgentSpaces return value that opens a votethe Escalator returns itthe vote capability
Rejecteda claim refused at registrationthe Registrarthe Ledgerintake
ClaimClosedthe terminal record: paid, denied, or rejected, with a summarythe Ledgeranyone, long after the flowledger

Deadline: a lease as a timer

What the stage must do

When ten seconds pass after the underwriter opens a vote and no decision exists, open an escalation vote, once per claim. When the panel decides in time, do nothing.

How AgentSpaces does it

A space already runs timers: it sweeps leases and emits an EXPIRED event when an entry's write lease lapses. A deadline is therefore an entry written with the deadline as its lease, and a reaction that listens for that entry's expiry.

// agentspaces/examples/example-18-agentic-workflow -- Decisions.Underwriter.open: writing the reminder
    decisions.write(new Reminder(priced.claimId()), reminderLease);
// agentspaces/examples/example-18-agentic-workflow -- Decisions.Escalator.escalate: reacting to the lapse
@SpaceNotify(space = SPACE, on = SpaceEvent.Kind.EXPIRED)
public Motion escalate(Reminder reminder) {
    if (votes.decision(PREFIX + reminder.claimId()).isPresent()) {
        return null;                       // decided in time: nothing to escalate
    }
    return Motion.of("escalate:" + reminder.claimId(), "The panel has not decided claim "
            + reminder.claimId() + " in time; escalate to the chief underwriter?",
            List.of("escalate", "wait"), 1, Lease.of(Duration.ofMinutes(10)));
}

The Underwriter writes a Reminder for the claim with a lease equal to the panel's deadline. on = SpaceEvent.Kind.EXPIRED tells the binder to call escalate when a Reminder's lease lapses rather than when the underwriter writes it. The escalator asks the vote capability, through its votes field, whether a decision for the claim exists; if so it returns null, and if not it returns a Motion, which the binder turns into a new proposal. Every peer sweeps its own replica, so every peer that hosts an Escalator sees the lapse; the binder opens a Motion's proposal id once, so three escalators open one escalation. The test staffs a fleet with no panel and a short deadline and counts exactly one escalation per claim.

The options

Option 1: a deadline. Write an entry whose lease is the deadline and react to its EXPIRED event, as the Underwriter and the Escalator do. Choose it whenever something must happen if nothing happens in time.

Option 2: cancel a deadline. Keep the EntryHandle that write returned and call cancel() when the outcome arrives, or do what the escalator does and let the reaction find the outcome and return null. The second costs one lookup per lapse and needs no bookkeeping.

Option 3: notice a dead worker. React to REAPPEARED on a task type. A worker took the task, its lease lapsed, and the space offered the task again; the reaction sees that happen. Choose it for a supervisor that records failures. The task is already back in the space, so the supervisor observes rather than retries.

Option 4: notice completion elsewhere. React to COMPLETED. Choose it when a stage wants to know a task finished without consuming its result. The test agent below listens to a write and its lapse with one method.

Option 5: make the escalation happen once. Every peer sweeps its own replica, so every escalator sees the lapse. Return a Motion, which the binder opens once per proposal id, or write a record carrying the claim id that the next stage dedups on.

The options at a glance

OptionAgentSpaces APIExample 18Choose it whenAlternative shown below
A deadlinea leased entry and @SpaceNotify(on = EXPIRED) on its typethe Reminder and the Escalatorif nothing happens within a time, something mustabove
Cancel a deadlineEntryHandle.cancel() on the reminder, or let the reaction find the outcome and return nullthe escalator finds the decisionthe outcome arrived in timeabove
Notice a dead worker@SpaceNotify(on = REAPPEARED) on the task typenot useda supervisor should record that a worker died holding an entry; the entry is already back in the space, so the supervisor observes rather than retriesAgentBinderVerbsTest.Both
Notice completion elsewhere@SpaceNotify(on = COMPLETED)not useda stage wants to know a task was done without consuming its result
Make the escalation happen oncereturn a Motion, or write a record the next stage dedups onthe Motion aboveevery replica sees the lapseabove
// agentspaces-agent tests -- AgentBinderVerbsTest.Both.see: one method for the write and the lapse
@SpaceNotify(space = "tasks", on = {SpaceEvent.Kind.WRITTEN, SpaceEvent.Kind.EXPIRED})
public void see(Reminder reminder) {
    kinds.add(reminder.id());
}
Sizing a deadlineThe reminder's lease is the deadline, and the space sweeps leases on its own tick, so a deadline resolves within one tick after it lapses. Set it to the time the panel honestly needs plus the replication delay, and never shorter than the vote's own lease, or the escalation can fire while a ballot is still in flight. Example 18 uses ten seconds in the normal flow and two seconds in the test that has no panel.

Loops

A loop is a stage that writes its own cue record again with a round counter, until a condition holds. The claims flow has no loop, because a loop would be contrived there; the shape is the Registrar's sealed return turned inward. A stage takes a Retry(claimId, round), does the work, and returns either the result record or a new Retry with the round advanced. Two guards keep it finite: the stage returns the result instead of a retry when the work succeeds, and a where = "round!=5" filter on the annotation keeps the stage from taking a retry that has exhausted its rounds, so the exhausted retry sits in the space for a deadline to find. The cue type and the output type must differ, or the stage must filter on a field it never produces, for the reason chapter 3 gives.

Compensation and the quarantine

What the stage must do

Close every case on the record, including the one the registrar refused, so that no claim disappears from the department's history.

// agentspaces/examples/example-18-agentic-workflow -- Settlement.Ledger.onRejection
@SpaceNotify(space = Intake.SPACE, resultSpace = LEDGER, resultLease = "24h")
public ClaimClosed onRejection(Rejected rejected) {
    return new ClaimClosed(rejected.claimId(), "REJECTED", rejected.reason());
}

The Ledger reacts to a Rejected in the intake space and writes a ClaimClosed into the ledger space under a twenty-four-hour lease. The same class has onPayment and onDenial for the other two outcomes, so every case closes through one agent whatever path it took. That is the smallest saga: a typed failure record, Rejected, and a compensating stage that reacts to it. A longer saga has one failure record per step that can fail and a compensating reaction per failure record, every record carrying the claimId. AgentSpaces supplies the records, the leases, and the correlation; the undo itself is yours.

The human step

A person is a stage. When a claim needs a manager's approval, the flow writes a request record for it and the manager writes an approval record through the console, the A2A gateway, or an application of your own. The flow waits for the approval the way it waits for anything: a join with the approval as a part, a Reminder written when the request went out, and an escalation when the reminder lapses. Nothing in the flow distinguishes a person from a worker. Lease the request for as long as a person takes, and put the deadline on the reminder, not on the request.

PatternAgentSpaces expressionExample 18Watch for
Deadlinea leased entry and @SpaceNotify(on = EXPIRED)the Reminder and the Escalatorevery replica sees the lapse: answer with a Motion or a deduplicated write
Dead worker@SpaceNotify(on = REAPPEARED) on the task typenonethe task is already back in the space
Loopa self-cue record with a round counter and a where guardnonea reaction that writes its own cue type loops without the guard
Compensationone failure record per step and one reacting stage per failure recordthe Rejected and the Ledgerthe undo is your code; carry the case id on every record
Human stepa request record, an approval record a person writes, a remindernonepeople are slow: a long lease on the request, a deadline on the reminder
aspace://workflow/models

11. Models and planners

Where we are

Example 18 has no model and no planner; every stage is plain Java. This chapter says where each would go, with the one checked-in model worker the Spring AI examples provide, so that you can place a model step or a planner in your own flow without changing the patterns of the other chapters.

A model as a stage

A model does a stage's work the way any worker does. The method is a @SpaceTake, the model call sits inside it, and the structured record the model returns is the method's return value. The take lease covers the model's latency, and a crash mid-call lets the lease lapse and another worker retry.

// agentspaces-springai/examples/springai-fleet -- SpringAiFleet.Summarizer.summarize: a ChatClient inside a take
@SpaceTake(space = "work", lease = "2m")
public Summary summarize(Brief brief) {
    return new Summary(brief.topic(), chat.prompt().user("Summarize: " + brief.topic()).call().content());
}

The Summarizer takes a Brief from the work space, asks a Spring AI ChatClient for a summary, and returns a Summary, which the binder writes as the completion result. In the claims flow, the DamageEstimator of chapter 3 is where a model would read the claim's description and produce the damage finding; it would become a take rather than a reaction, because a model call can fail and should retry, and its lease would match the model's latency. The developer guide's Spring AI chapter covers the TakeLeaseAdvisor, which renews the take lease during a long call, and the fleet tools a model may choose between.

A planner as a composer

A planner works the other way round: it reads the cards of the agents in the group, treats each card's consumed and produced record types as an action, and composes a route from a starting record to a goal record. The Embabel bridge in the developer guide turns the fleet's cards into such actions. Invoking one is still a write of the consumed record into the right space and a correlated read of the produced record, so a planner composes the stages this guide describes; it does not replace them.

A model as a stageA planner as a composer
Where it livesinside a @SpaceTake method on one agentabove the group, reading its cards
What it decidesthe content of one recordwhich stages run, in what order
Retrythe take leasethe planner's own loop; a remote action that times out returns empty
Choose it whenone step needs judgement over text or datathe route to a goal depends on which skills are available right now
Read moreSpring AI workersEmbabel remote actions
aspace://workflow/data

12. The shape of data

Where we are in the claims flow

Stage 2 again, the DamageEstimator. Example 18's records mirror a small claims vocabulary, an ontology with classes such as Claim and DamageFinding. Most of the flow carries that vocabulary as one Java record per class. The damage finding travels differently, as a generic instance of its class, to show the second way an ontology reaches a space.

The records in play

Record (the claims flow's POJO)What it means in the businessWho writes itWho reads itSpace
Instancea generic ontology individual: its id, its class name, the class IRI, and the properties as a map; here a DamageFinding with an estimate, a severity, and the claim id among its propertiesthe DamageEstimatorthe Assemblerintake
Taggednot a record of the flow: the AgentSpaces return value that pairs a record with the tags to write on its entrythe DamageEstimator returns itthe binder

Route A: one record per class

Every record in chapter 1 is this route. A namespace becomes a package, a class becomes a record, a datatype property becomes a component, an object property becomes a component holding the referent's id, and a class hierarchy becomes one record per leaf class or one record with a discriminator field. Every property then becomes a component a template can filter on, which is the strongest matching the space offers, and you pay one record per class.

Route B: entries as ontology instances

// agentspaces/examples/example-18-agentic-workflow -- Instance
public record Instance(String id, String type, String typeIri, Map<String, Object> properties) {

One record carries any individual of any class. A template can match the components id, type, and typeIri; it cannot look inside properties, because a field condition reads one accessor and the map is one value. So anything a stage must match on moves out of the map: into a component, or into a tag on the entry. The map itself, like every map a signature covers (the entry's tags, a card's bindings), is encoded in canonical order on the wire since wire version 3, shorter UTF-8 keys first and bytewise between equal lengths, so an instance written by a Java peer and read by a Python or TypeScript one verifies whatever order its properties were built in.

// agentspaces/examples/example-18-agentic-workflow -- Intake.DamageEstimator.estimate: tags carry what a template can see
@SpaceNotify(space = SPACE, resultLease = "1h")
public Tagged<Instance> estimate(RegisteredClaim claim) {
    double estimate = Math.round(claim.claimedAmount() * 0.9);
    Instance finding = Instance.damageFinding(claim.claimId(), estimate,
            severity(claim.claimedAmount()), "damage-estimator");
    // The tags carry what a template can select on before decoding: the
    // correlation key the join needs, and the class IRI (SPEC §7.2, §11).
    return Tagged.of(finding, "claimId", claim.claimId(), "rdf:type", finding.typeIri());
}

The DamageEstimator builds a DamageFinding instance and returns it wrapped in Tagged.of(record, key, value, key, value). The binder writes the instance as an entry with two tags: claimId, which the Assembler in chapter 4 uses as the part's key through keyTag = "claimId", and rdf:type, which the assembler filters on through tags = "rdf:type=..." so that it joins damage findings and not some other class carried the same way. The assembler then reads the estimate and the severity out of the properties with damage.number("estimate") and damage.string("severity").

Route C: naming types by IRI

By default an entry's type name on the wire is the record's fully qualified class name with a version fragment. A NamespaceSchemaRegistry names the records of chosen packages by an ontology namespace instead, so that records, cards, remote actions, and the Python and TypeScript clients' constants all use the class IRI: NamespaceSchemaRegistry.of(Map.of("org.example.claims", "https://example.org/claims#")), given to the space builders and the binder, or a SchemaRegistry bean under the Spring Boot starter. The library's own reserved types keep their names.

The options

Route A: one record per class. Write a Java record for each class of the vocabulary. Every property is a component a template can filter on, every stage declares the concrete record it consumes, and the card tells the truth. Choose it when the vocabulary is stable and stages filter on many properties. Example 18 takes this route for every record but one.

Route B: generic instances. Carry each individual as an Instance with its class and a property map, and promote the properties stages match on into tags with Tagged.of. Choose it when the vocabulary changes faster than the code, or when one flow carries individuals of many classes. Example 18 takes this route for the damage finding, and chapter 4's assembler shows what it costs: a keyTag and a tags filter on the part instead of a field.

Route C: types named by IRI. Keep either route and add a NamespaceSchemaRegistry, so that the type name on every entry, card, and client constant is the class IRI. Choose it when systems outside the fleet, or clients in other languages, should recognise the ontology's names on the wire.

In every route: validate at the door. Check the vocabulary's constraints in the first take and return a Rejected record for incongruent data, as the Registrar does, so that the ledger closes the case instead of the flow losing it.

The options at a glance

OptionAgentSpaces APIExample 18Choose it whenAlternative shown below
One record per classplain Java recordsevery record but the damage findingthe vocabulary is stable and stages filter on many propertieschapter 1
Generic instancesa record with a property map, and Tagged.of for the properties stages match onthe damage findingthe vocabulary changes faster than the code, or one flow carries individuals of many classesabove
Types named by IRINamespaceSchemaRegistry on the spaces and the bindernot used; the test suite runs the fleet under one to show the names on cards and recordscards, records, and clients should speak the ontology's names
Validation at the dooryour checks in the first take, with a Rejected record as the outcomethe Registraralways; incongruent data becomes a record the ledger closes rather than an exception the flow loseschapter 2
Which route to takeRoute A when your records are the design and will outlive the ontology file. Route B when the ontology file is the design and the code should follow it without a recompile per class. Route C on top of either when the names on the wire matter to someone other than your Java code. Mixing routes in one flow is fine, as example 18 shows; the join in chapter 4 keys route A parts by field and the route B part by tag.
What a relay can seeAn entry's type name and its tags stay in plain text under content encryption, so that replicas and relays without the content key can still match and route. Naming a type by its class IRI, or tagging an entry with one, tells every relay what the data is about. Decide that once, when you choose the route.
aspace://workflow/testing

13. Running, observing, and testing

Where we are

The whole flow. Example 18 runs as three peers over TCP, with five spaces and three capabilities on each, and its test runs the same fleet with a crash arranged. This chapter shows how example 18 assembles a peer, seats the agents, and asserts the outcome, so that your own flow has the same three parts.

Seating the agents

// agentspaces/examples/example-18-agentic-workflow -- ClaimsFlow.staff: who sits on which peer
bound.add(a.group().bind(new Intake.Registrar("registrar-a", hook)));
bound.add(b.group().bind(new Intake.Registrar("registrar-b", hook)));
bound.add(a.group().bind(new Intake.FraudScreen()));
bound.add(b.group().bind(new Intake.CoverageCheck()));
bound.add(c.group().bind(new Intake.DamageEstimator()));

group().bind(agent) hands an agent to the binder of that peer's group and returns a handle that unbinds it when closed. Roles that must be singletons in the flow, the Assembler, the Underwriter, the Approver, the Ledger, the Supervisor, sit on one peer. Roles that are one per peer, the Panelist, the Payer, the ReserveSensor, sit on all three. The two Registrars show the lease race, and the two adjusters show the auction.

Arranging the crash

// agentspaces/examples/example-18-agentic-workflow -- ClaimsFlowTest: the hook that throws once
// Crash the first registration any registrar attempts, and count only the
// attempt that actually crashed (two registrars race for the same flag).
Intake.Hook counted = (worker, claim) -> {
    if (armed.compareAndSet(true, false)) {
        crashes.incrementAndGet();
        throw new IllegalStateException("simulated crash in " + worker);
    }
};

The Hook is the example's own interface, a callback the Registrar calls before registering, so that a test can throw inside the take. The test then asserts one property per stage, each by reading a space: every case closes once with its expected outcome; the crash happened once and every claim still registered; one assessment per claim however many joiners; the senior adjuster priced the severe claim; the panel split and still approved; exactly one payment per approved claim at every peer, folded once into its claimant's exposure; three quotes gathered and the lowest chosen; only the severe claim reviewed; no reminder escalated in the normal flow and one per claim when there is no panel; one reserve report at the four-claim average.

The harnesses

HarnessWhat it exercisesExampleUse it for
A LocalSpace and one binderthe binding's logic: filters, joins, folds, leases under a test clockthe binder's own tests, such as AgentBinderGatherTestevery stage, in milliseconds; no replication, so no lease race and no outrun part
Three peers on a simulated network and a test clockreplication, the lease race, the ordered log, the voteAgentBinderJoinClusterTest, AgentBinderReduceClusterTestthe fleet-wide promises: once per key, exactly once, quorum
The same under CPU load, with many keys and the debug log captured per keythe timing a test clock hides: a ticket outrunning a part, a lease lapsing under a drainAgentBinderJoinClusterStressTest with -Djoin.stress=trueany flake in a fleet-wide test; treat one failure in eight as a bug until its timeline says otherwise
Three peers over TCP, the real examplethe whole flow as deployedClaimsFlowTestthe acceptance run; ten green runs after any timing change
A stub peer with no socket, in Python or TypeScripta decorated agent's takes, notifies, ballots, decisions, joins, forks, and motions; the capability pipes on an in-memory networktests/test_layer34.py, test_layer4.py, test_verbs.py; test/layer34.test.ts, layer4.test.ts, verbs.test.tsa stage written in another language, in milliseconds
A Java coordinator with Python and Node workers in their own processesthe wire between languages: tasks, a council vote, a push-sum epoch, a semantic query over the pipe, a remote actionCouncilCrossLanguageTest (example 02), the second act of run-trilingual-demo.shany stage of a flow that spans languages; it taught that a dial-only peer sends capability frames only to members it can reach

The fleet console shows the take ledger, which agent holds which entry under which lease, and the data map, which record types flow through which spaces with their tags. Both are the first place to look when a flow stalls; the wire-traffic panel, frames and bytes by kind as rates with the frames dropped as stale, replayed or unverifiable beside them, is where to look when a flow is slow rather than stuck. The checklist a flow leaves this guide with: every record carries the case id; every stage is a take or is safe to repeat; every effect that must happen once across the group is an ordered take, an ordered join, a vote, or a deduplicated write; every wait has a deadline; every failure is a record; and the test reads every property from a space.

Further reading

The developer guide is the reference for each piece this guide composes: every annotation and attribute, the Space API, the conflict strategies, the capabilities, the console, and the same verbs from Python, TypeScript, and Clojure. The architecture guide decides what a flow like this should be built on: how many peers, which spaces and groups, which rung of the consistency ladder each stage stands on, and when a decision is a vote. Example 18's README walks the flow stage by stage with the test's assertions.