Osy#betaa language · its runtime Osyrin · a hosted platform
Why Osy#Built for agentsAgents as declarationsWorkflows that waitRuns exactly onceSecure by defaultNothing to mockThe editor is the compilerUI in the languageDocuments are dataOne program

Reference / Workflow

enter and exit (a state's arrival and departure hooks)

state Open { enter { … } exit { … } }

Run code when a run arrives in a state, and when it leaves. enter may redirect the run somewhere else; exit may not, because by the time it runs the move has already been decided. Both are optional and neither changes when the state's deadline clock counts.

stable2 examples compiled by CIworkflowauthoring

Summary#

A state can run code at two moments: when a run arrives in it, and when a run leaves it.

state UnderReview {
  enter { this.Item.ReviewStartedAt = DateTime.UtcNow; }
  exit  { this.Item.ReviewMinutes = (DateTime.UtcNow - this.Item.ReviewStartedAt).TotalMinutes; }
}

Both are optional, and both run inside the same transaction as the move that triggered them — so if either throws, the move does not happen at all.

Signature#

state <Name> {
  enter { <statements> }    // on arrival — MAY `goto` to redirect the run
  exit  { <statements> }    // on departure — may NOT `goto`
}

Description#

Can enter/exit redirect with goto?#

enter may redirect: enter { if (order.Total > 10000) goto NeedsApproval; } sends the run somewhere else instead of settling in this state. That works because on arrival, where the run ends up is still the thing being decided.

exit may not, and a goto in one is a compile error. By the time an exit body runs the transition has already been decided — something chose the destination, and this body is running because of that choice. A redirect here would either silently override a decision already made, or, if two states' exit bodies each redirected into the other, bounce between them for ever with no arm to break the cycle.

The refusal names where the decision does belong:

an `exit { }` body may not `goto` — state 'Open' runs its exit body BECAUSE a transition was already decided, so a
`goto` here would override a choice already made (and two states redirecting to each other would never settle). Put
the decision where it is still open: an `on` arm, a `complete when`, or the destination state's own `enter { }`,
which MAY redirect.

It reads the same way as the rule for milestones, which likewise may not goto from their enter: a body that runs as a consequence of a decision does not get to re-make it.

In what order do exit, enter and the deadline run?#

Leaving A for B runs, in one transaction:

  1. A's exit { }
  2. B's enter { }
  3. B's waits and deadline are armed

So an exit body sees the world as it was in the state it is leaving, before anything about the destination applies.

Deadlines and Accrues#

A state's SLA clock has already stopped by the time its exit body runs. Accrues measures time spent in the state, and work done on the way out is not that — so an exit body can never inflate the very measurement it is often there to record.

What happens if a body reaches the network?#

Wrap it. A body here is not an ordinary function: nobody called it, so there is nobody to hand a failure to.

A timeout, a DNS failure or a refused connection is not a workflow outcome — the platform treats it as worth another attempt, so the delivery is retried and this whole body runs again from the top, with the row still sitting in the state it was arriving in. That is right for a blip and wrong for an endpoint that is simply gone, and either way it is not what you would have chosen if you had been asked.

osy lint asks for you, in every body a workflow declares — enter, exit, an on route, a milestone arm, a Remind. Three findings cover the two ways a call can go wrong, and they cover the reach through a helper as well as the call written here, because a body that says only SendMail(this.Item) warns you of nothing in its own text:

findingwhat it saw
reliability-outbound-call-unguardedan outbound call written in this body, with no try
reliability-reaches-the-network-unguardedthis body reaches the network through something it calls, with no try
reliability-http-result-uncheckedan Http.* result used without asking whether the call worked — a 404 is a normal return and no try can catch it

A helper that catches everything itself counts as handled, however many hops down it is, so you write the guard once where it means something rather than at every level.

class MailRequest { public string To; public string Subject; }
class MailResult  { public string Id; }

client Mailer {
  BaseUrl = "https://api.mail.example";
  [Post("/send")] MailResult Send(MailRequest body);
}

entity Invitation {
  [Required, MaxLength(200)] string Email;
  InviteStatus Status;   // no default: `InviteFlow` autostarts, so its `Initial` IS this field's first value
}
enum InviteStatus { Pending, Accepted }

// The guard lives HERE, in the one place that knows what a failed send means: the invitation is still open, the run
// still moves on, and the failure is findable. Without it the send is retried with everything above it.
void SendInvite(Invitation inv) {
  try {
    var sent = Mailer.Send(new MailRequest { To = inv.Email, Subject = "You are invited" });
  }
  catch (Exception e) {
    Log.Error(e, "invitation mail failed for {Email}", inv.Email);
  }
}

workflow InviteFlow {
  Tracks    = Invitation.Status;
  Autostart = true;
  Initial   = Pending;

  event Accept();

  state Pending {
    enter { SendInvite(this.Item); }
    subscribe Accept() as Acceptance;
    on Acceptance { goto Accepted; }
  }

  terminal success Accepted { }
}

Do enter/exit run on a terminal state?#

A terminal state can declare an enter { }, which runs as the run finishes. It can declare an exit { } too, but nothing will ever run it: a terminal is where runs stop. Prefer putting the work in enter.

Examples#

Recording how long a review took — the pair working together, which is what exit exists for:

entity Review {
  [Required, MaxLength(200)] string Title;
  DateTime? StartedAt;
  int Minutes;
  ReviewStatus Status = ReviewStatus.Pending;
}
enum ReviewStatus { Pending, UnderReview, Done }

workflow ReviewFlow {
  Tracks    = Review.Status;
  Autostart = false;
  Initial   = Pending;

  event Begin();
  event Finish();

  state Pending {
    subscribe Begin() as B;
    on B { goto UnderReview; }
  }

  state UnderReview {
    enter { this.Item.StartedAt = DateTime.UtcNow; }
    // Runs on the way out, whichever arm caused the move — so a second way out of this state cannot forget it.
    exit  { this.Item.Minutes = this.Item.Minutes + 1; }

    subscribe Finish() as F;
    on F { goto Done; }
  }

  terminal success Done { }
}

That last point is the practical argument for exit over repeating the line at the end of every arm: a state with three ways out needs the bookkeeping written once, and a fourth arm added later cannot silently omit it.

See also#

Related

Tracks and Initial (the field a workflow drives)

Names the enum field a workflow owns and the state a run starts in. No application code may write that field, and when…

complete when (a state's own completion condition)

Declare, once on a state, the condition under which that state is finished and where the run goes next. It is…

Assigned / Finished (milestones)

A milestone puts an SLA on a slot's progress — Assigned (someone must PICK IT UP within Within) and Finished (it must…

ServiceHours (SLA-accrual windows)

A schedule the SLA clock accrues within — the platform WALKS its weekly windows (and holiday exceptions) to advance…

a typed HTTP client (client)

A `client` block declares a typed wrapper around an external HTTP API: name the BaseUrl once, then declare each…