Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Samples

wf-order-saga

wf-order-saga — the ORDER-LOB workflow demo.

7 source files2 test files

The wf-order-saga sample, running.
Running, signed in, compiled from the source below.

Get it

// not one of the apps the toolchain ships: this one lives in the
// repository. Clone it, then:
$ cd demo/wf-order-saga
$ osy launch

The app

app.osy8 lines
// wf-order-saga — the ORDER-LOB workflow demo.
app WfOrderSaga {
  use Osyrin.Ui;
  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
  data  "data/**/*.json";
}
model/actions.osy18 lines
// The board's verbs, as ordinary app functions.

void PayOrder(Order o)     { OrderLifecycle.RaisePay(o); }

void ShipOrder(Order o, string tracking) { OrderLifecycle.For(o).Fulfil.Ship(tracking); }

void DeliverOrder(Order o) { OrderLifecycle.RaiseDeliver(o); }

void CancelOrder(Order o)  { OrderLifecycle.RaiseCancel(o); }

void RefundReturn(ReturnShipment r) { ReturnFlow.RaiseRefund(r); }

void PlaceOrder(string reference, decimal total) {
  var customer = Customer.First();
  new Order { Reference = reference, Customer = customer, Total = total };
  UnitOfWork.Commit();
}
model/identity.osy55 lines
// The demo's identity model — WHO the staff are, and on what authority.

[Role] enum AppRole { Authenticator, Fulfillment, Manager }

[Principal]
entity Staff {
  [Required, MaxLength(100)] string Name;
  [Required, MaxLength(200), Unique] string Email;
  [MaxLength(200)] string? PasswordHash;
  security {
    allow read when IsAuthenticated;
    allow read, create when IsAuthenticator;
    allow create when IsManager;
    allow update when IsManager;
    deny read PasswordHash when !IsAuthenticator;   // nobody but the auth flow ever sees the hash
  }
}

entity RoleGrant {
  [Required] Staff Grantee;
  [Required] AppRole Level = AppRole.Fulfillment;
  security {
    allow read when IsAuthenticated;
    allow create when IsAuthenticator;               // …the signup, for the first grant
    allow create, update, delete when IsManager;     // all three verbs, or the weakest is the way in
  }
}

policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator);
policy IsManager       => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Manager);

[AuthMethod]
string Signup(string email, string password) {
  var s = new Staff { Name = email, Email = email, PasswordHash = Security.HashPassword(password) };
  return Security.IssueJwt(s.Id, s.Email);
}

[AuthMethod]
string Login(string email, string password) {
  var s = Staff.Where(x => x.Email == email).FirstOrDefault();
  if (s == null) { Security.VerifyPassword(password); return ""; }
  if (s.PasswordHash == null) { return ""; }
  if (Security.VerifyPassword(password, s.PasswordHash)) { return Security.IssueJwt(s.Id, s.Email); }
  return "";
}

app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash };

app.AuthBootstrap = new AuthBootstrap {
  Role      = AppRole.Authenticator,
  Login     = Login,
  Signup    = Signup,
  LoginPage = LoginPage,
};
model/order.osy175 lines
// ORDER LIFECYCLE — the order-LOB saga: a payment step, a fulfillment slot, shipping, delivery, and cancel-from-anywhere
// with a COMPENSATING RETURN. Cancelling a SHIPPED order can't just flip a flag — the goods are gone — so it runs a
// child return workflow and awaits it, landing the order in `Returning` only once the refund lands. That awaited child
// (`await Workflow.Run`) PARKS the order workflow durably and resumes it when the return terminals (the saga tier).
// The identity tier — `Staff`, `AppRole` and the auth methods — lives in `identity.osy`, beside every other demo's.
enum OrderStatus { Placed, Paid, Shipped, Delivered, Cancelled, Returning }
enum ReturnStatus { Initiated, Refunded }

entity Customer {
  [Required, MaxLength(200)] string Name;
  [Required, MaxLength(255)] string Email;
  security {
    allow read when IsAuthenticated;
    allow create when IsManager;
    allow update when IsManager;
  }
}

entity Order {
  [Required, MaxLength(50)] string Reference;
  OrderStatus Status;
  [Required] Customer Customer;
  decimal Total;
  [MaxLength(100)] string? Tracking;
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

entity ReturnShipment {
  [Required] Order Order;
  ReturnStatus Status = ReturnStatus.Initiated;
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

workflow ReturnFlow {
  Tracks    = ReturnShipment.Status;
  Autostart = false;                 // started by `Workflow.Run` from the order's Cancel route, not on create
  Initial   = Initiated;

  event Refund();

  state Initiated {
    subscribe Refund();
    on Refund { goto Refunded; }
  }

  terminal success Refunded { }
}

workflow OrderLifecycle {
  Tracks    = Order.Status;
  Autostart = true;
  Initial   = Placed;

  event Pay();
  event Ship(string tracking);
  event Deliver();
  [Authorize(u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Manager))]
  event Cancel();

  on Cancel {
    if (this.Item.Status == OrderStatus.Shipped) {
      var ret = Workflow.Once("make-return", () => new ReturnShipment { Order = this.Item });
      await Workflow.Run("ret", ret);          // hold until the return workflow refunds
      goto Returning;
    } else {
      goto Cancelled;
    }
  }

  state Placed {
    subscribe Pay();
    on Pay { goto Paid; }
  }

  state Paid {
    subscribe Ship(string tracking) as Fulfil {
      Candidates = u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Fulfillment);
    }
    on Fulfil(string tracking) {
      this.Item.Tracking = tracking;
      goto Shipped;
    }
  }

  state Shipped {
    subscribe Deliver();
    on Deliver { goto Delivered; }
  }

  terminal success Delivered { }
  terminal cancel  Cancelled { Message = "order cancelled"; }
  terminal cancel  Returning { Message = "order cancelled after shipping — return + refund issued"; }
}

enum FulfillmentStatus { Building, Fulfilled, Aborted }
enum StepStatus        { Waiting, Done, Bad }

entity Fulfillment {
  [Required, MaxLength(50)] string Reference;
  FulfillmentStatus Status;
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

entity Reservation { [Required] Fulfillment Fulfillment; StepStatus Status = StepStatus.Waiting; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } }
entity Charge      { [Required] Fulfillment Fulfillment; StepStatus Status = StepStatus.Waiting; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } }
entity Dispatch    { [Required] Fulfillment Fulfillment; StepStatus Status = StepStatus.Waiting; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } }

entity Trace { [Required] Fulfillment Fulfillment; [MaxLength(20)] string Log; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } }

void ReleaseStock (Reservation r, Trace t) { t.Log = t.Log + "S"; Log.Information("  ↩ rollback: released reserved stock"); }
void RefundCharge (Charge c,      Trace t) { t.Log = t.Log + "C"; Log.Information("  ↩ rollback: refunded the charge"); }
void ReleaseHold  (Trace t)                { t.Log = t.Log + "H"; Log.Information("  ↩ rollback: released the loyalty hold"); }

workflow ReserveFlow {
  Tracks = Reservation.Status; Autostart = false; Initial = Waiting;
  event Finish();
  state Waiting { subscribe Finish(); on Finish { goto Done; } }
  terminal success Done { }
  terminal error   Bad  { Message = "reservation failed"; }
}

workflow ChargeFlow {
  Tracks = Charge.Status; Autostart = false; Initial = Waiting;
  event Finish();
  state Waiting { subscribe Finish(); on Finish { goto Done; } }
  terminal success Done { }
  terminal error   Bad  { Message = "charge failed"; }
}

workflow DispatchFlow {
  Tracks = Dispatch.Status; Autostart = false; Initial = Waiting;
  event Finish();
  state Waiting { subscribe Finish(); on Finish { goto Bad; } }   // the courier hand-off always fails
  terminal success Done { }
  terminal error   Bad  { Message = "dispatch failed"; }
}

workflow FulfillmentSaga {
  Tracks    = Fulfillment.Status;
  Autostart = true;
  Initial   = Building;

  state Building {
    enter {
      var trace = Workflow.Once("make-trace", () => new Trace { Fulfillment = this.Item, Log = "" });
      Log.Information("▶ fulfillment {Reference}: starting saga", this.Item.Reference);
      await using var saga = Workflow.BeginSaga();      // dispose-without-Complete unwinds the committed steps, in reverse
      try {
        Log.Information("  ✓ step 1: reserving stock");
        var reservation = Workflow.Once("make-reservation", () => new Reservation { Fulfillment = this.Item });
        await saga.Run("reservation", reservation, () => ReleaseStock(reservation, trace));   // step 1 + its undo, coupled

        Log.Information("  ✓ step 2: charging the card");
        var charge = Workflow.Once("make-charge", () => new Charge { Fulfillment = this.Item });
        await saga.Run("charge", charge, () => RefundCharge(charge, trace));             // step 2 + its undo

        saga.OnUnwind(() => ReleaseHold(trace));        // a compensation with NO forward step (a loyalty hold booked inline)

        Log.Information("  ✓ step 3: dispatching the courier");
        var dispatch = Workflow.Once("make-dispatch", () => new Dispatch { Fulfillment = this.Item });
        await saga.Run("dispatch", dispatch);                        // step 3 — no undo; this child FAILS

        saga.Complete();                                 // never reached
        goto Fulfilled;
      } catch (WorkflowError e) {
        Log.Information("✗ fulfillment {Reference}: a step failed — rolling back committed steps in reverse", this.Item.Reference);
        goto Aborted;                                    // the goto unwinds through the await-using dispose → H, C, S
      }
    }
  }

  terminal success Fulfilled { }
  terminal error   Aborted   { Message = "fulfillment aborted — reservation and charge compensated"; }
}
model/theme.osy73 lines
// THE SHARED DEMO THEME — canonical copy. Every demo listed in `DemoSharedThemeTests` holds a
// byte-identical `model/theme.osy`, and that test is what keeps them identical.
//
// ⚑ EVERY COLOUR HERE SHADOWS A TOKEN THE KIT ALREADY DECLARES, and that is the whole point.
//   The themes this replaced declared a PARALLEL palette — `Surface0/1/2`, `TextPrimary`,
//   `FillAccent/Success/Warning/Danger` — names `Osyrin.Ui` has never heard of. So the kit's own
//   `Primary`, `Success`, `Warning` and `Danger` stayed at their defaults in six demos: the moment
//   one of them reached for a kit control, that control painted itself indigo next to the demo's
//   blue. `FillAccent` was declared in all six and referenced by NONE — an accent colour that
//   existed only in the theme file.
//
// So the rule for a demo theme is: SHADOW a kit token, never invent a second name for it. A name
// the kit does not have (`Radius.Card`) is a real extension and fine; a second spelling of one it
// does have (`TextPrimary` for `OnBg`) is how an app ends up with two of everything.
//
// Excluded by design — these five own their look and must NOT adopt this file: arcade, ember,
// motion, gestures (each demonstrates a visual world) and Apps/recall-osy.

theme Demo {

  Colors {
    // A deep petrol blue, deliberately not the kit's indigo (#4F46E5) — a demo should look like a
    // considered app rather than an unstyled one, and the two should be distinguishable on sight.
    // It is the one saturated colour on the page; everything else is warm neutral, which is what
    // keeps "distinct" from turning into "loud".
    Primary   = Modes.Of(light: Palette.From("#125E7A"), dark: Palette.From("#57B8D6"));
    OnPrimary = Modes.Of(light: "#FFFFFF", dark: "#06181F");

    Bg      = Modes.Of(light: "#FAF9F7", dark: "#101317");   // the page — a touch warm, so cards read as lifted
    Surface = Modes.Of(light: "#FFFFFF", dark: "#171C22");   // a card, a row, a lane
    Muted   = Modes.Of(light: "#EFEEEA", dark: "#1E242B");   // an inline notice, a disabled field
    Border  = Modes.Of(light: "#E4E2DC", dark: "#28303A");

    OnBg          = Modes.Of(light: "#14181D", dark: "#E7EBF0");
    OnSurface     = Modes.Of(light: "#14181D", dark: "#E7EBF0");
    TextSecondary = Modes.Of(light: "#59626D", dark: "#9BA6B3");   // labels, timestamps, captions
    TextMuted     = Modes.Of(light: "#7C848E", dark: "#7E8894");   // the quietest text on the page

    Success = Modes.Of(light: Palette.From("#1D7A4C"), dark: Palette.From("#4EC98A"));
    Warning = Modes.Of(light: Palette.From("#B26A00"), dark: Palette.From("#E0A343"));
    Danger  = Modes.Of(light: Palette.From("#C1352B"), dark: Palette.From("#F0736A"));
  }

  // Names the kit does not have, so these are extensions rather than second spellings.
  Radius { Control = "8px"; Card = "12px"; }

  // ⚑ THE FONTS ARE WHAT MAKE THIS READ AS A DESIGNED APP RATHER THAN A DEFAULT ONE, and `Sans`
  //   shadows the kit's own token — so every kit control picks it up with no page edit at all.
  //   Both faces are vendored per app under `model/fonts/` and pinned in `osyrin.lock` (`osy font
  //   add`), because a webfont a page merely NAMES is a webfont that silently falls back.
    //   Only the faces the app SHIPS are named here — Mono is left at the kit default because no
  //   demo renders code, and naming an unshipped family is a silent fallback.
  //   Geist for the UI: a neutral grotesque with real character at small sizes, which is where a
  //   dense LOB screen lives. Fraunces for display: a warm variable serif, used ONLY on a page
  //   title. The pairing is the whole look — one voice for reading, one for announcing.
  Font {
    Sans    = "Geist, ui-sans-serif, -apple-system, \"Segoe UI\", Roboto, system-ui, sans-serif";
    Serif   = "Fraunces, \"Iowan Old Style\", Palatino, Georgia, ui-serif, serif";
  }

  FontSize   { Caption = "12px"; Body = "14px"; Subhead = "17px"; Title = "26px"; }
  FontWeight { Regular = 400; Medium = 500; Semibold = 600; }

  // ── APP EXTENSIONS ─────────────────────────────────────────────────────────────────────────────
  // Everything ABOVE this line is the shared theme, held byte-identical across the demos by
  // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may
  // NOT add a second spelling of one the kit already has; that is the defect this file replaced.
  // ── APP EXTENSIONS ─────────────────────────────────────────────────────────────────────────────
  // Everything ABOVE this line is the shared theme, held byte-identical across the demos by
  // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may
  // NOT add a second spelling of one the kit already has; that is the defect this file replaced.
}
model/pages/board.osy131 lines
// THE BOARD — and the thing it has to make visible is that CANCELLING A SHIPPED ORDER IS NOT A FLAG. The goods are
// gone, so the workflow starts a compensating RETURN child and PARKS on it; the order ends in `Returning`, not
// `Cancelled`, and the difference between those two words is the entire demo.
using Osyrin.Ui;

[Page("/")]
[Render(CSR)]
[Title("Orders")]
component Board() {
  live var orders  = Order.Include(o => o.Customer).OrderByDescending(o => o.CreatedAt).ToList();
  live var returns = ReturnShipment.Include(r => r.Order).ToList();

  live var mine = Workflow.Inbox<Order>().Include(r => r.Item);

  var me = Session.CurrentUser;

  string reference = "ORD-1004";
  decimal total = 249;
  string tracking = "TRK-9001";

  action Pay(Order o)     { PayOrder(o); }
  action Ship(Order o)    { ShipOrder(o, tracking); }
  action Deliver(Order o) { DeliverOrder(o); }
  action Cancel(Order o)  { CancelOrder(o); }
  action Refund(ReturnShipment r) { RefundReturn(r); }
  action Place()   { PlaceOrder(reference, total); }
  action SignOut() { Session.SignOut(); }

  render {
    Stack(gap: 0, minH: "100vh", bg: Colors.Bg, color: Colors.OnBg) {

      Row(justify: Justify.Center, w: "100%", bg: Colors.Surface, borderW: 1, border: Colors.Border) {
        Row(align: Align.Center, justify: Justify.SpaceBetween, w: "100%", maxW: "980px", px: 5, h: "60px") {
          Text("Orders", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
          Row(align: Align.Center, gap: 3) {
            Text(me.Name, fontSize: FontSize.Caption, color: Colors.TextSecondary);
            if (IsManager) { Text("Manager", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success); }
            else           { Text("Fulfillment", fontSize: FontSize.Caption, color: Colors.TextSecondary); }
            Pressable(onClick: SignOut) { Text("Sign out", fontSize: FontSize.Caption, color: Colors.TextSecondary); }
          }
        }
      }

      Row(justify: Justify.Center, align: Align.Start, grow: 1, minW: "0") {
        Stack(gap: 5, w: "100%", maxW: "980px", p: 5) {

          Stack(gap: 2) {
            Text("Orders", fontFamily: Font.Serif, fontSize: FontSize.Title, fontWeight: FontWeight.Semibold);
            Text("Place → pay → ship → deliver. After shipping, cancelling runs a return instead.",
                 fontSize: FontSize.Caption, color: Colors.TextSecondary);

            foreach (var o in orders) {
              Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
                Stack(gap: 3) {
                  Row(justify: Justify.SpaceBetween, align: Align.Center) {
                    Stack(gap: 1) {
                      Text(o.Reference + " · " + o.Customer.Name, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold);
                      Text(o.Total + (o.Tracking != null ? " · " + o.Tracking : ""),
                           fontSize: FontSize.Caption, color: Colors.TextSecondary);
                    }
                    if (returns.Any(r => r.Order == o && r.Status == ReturnStatus.Initiated)) {
                      Text("Return in flight — refund pending", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Warning);
                    } else if (o.Status == OrderStatus.Returning) {
                      Text("Returning — refund in flight", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Danger);
                    } else {
                      Text(o.Status, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium);
                    }
                  }

                  Row(gap: 2, align: Align.Center) {
                    if (o.Status == OrderStatus.Placed) { Button("Pay", onPress: () => Pay(o), tone: Tone.Primary); }

                    foreach (var r in mine) {
                      if (r.Item == o && r.SlotAlias == "Fulfil") { Button("Ship", onPress: () => Ship(o), tone: Tone.Primary); }
                    }

                    if (o.Status == OrderStatus.Shipped) { Button("Mark delivered", onPress: () => Deliver(o), tone: Tone.Primary); }

                    if (IsManager
                        && o.Status != OrderStatus.Cancelled
                        && o.Status != OrderStatus.Returning
                        && o.Status != OrderStatus.Delivered
                        && !returns.Any(r => r.Order == o && r.Status == ReturnStatus.Initiated)) {
                      Button("Cancel order", onPress: () => Cancel(o), tone: Tone.Danger);
                    }
                  }
                }
              }
            }
          }

          if (returns.Count() > 0) {
            Stack(gap: 2) {
              Text("Returns in flight", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
              Text("A cancelled shipped order parks here until the refund lands. THEN the order moves.",
                   fontSize: FontSize.Caption, color: Colors.TextSecondary);

              foreach (var r in returns) {
                Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
                  Row(justify: Justify.SpaceBetween, align: Align.Center) {
                    Stack(gap: 1) {
                      Text("Return for " + r.Order.Reference, fontSize: FontSize.Body, fontWeight: FontWeight.Medium);
                      Text(r.Status, fontSize: FontSize.Caption, color: Colors.TextSecondary);
                    }
                    if (r.Status == ReturnStatus.Initiated) {
                      Button("Refund", onPress: () => Refund(r), tone: Tone.Primary);
                    }
                  }
                }
              }
            }
          }

          Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
            Stack(gap: 3) {
              Text("Place an order", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
              Row(gap: 3, align: Align.End) {
                Box(grow: 1) { Field("Reference", value: reference, placeholder: "ORD-1004"); }
                // `total` is a `decimal` and `Field` is a two-way binding of `string`, so the numeric field is
                // the one that binds it directly — the resolver's own `Binding<T>` check says exactly that.
                DecimalField("Total", value: total, min: 0m);
                Button("Place", onPress: Place, tone: Tone.Primary);
              }
            }
          }
        }
      }
    }
  }
}
model/pages/login.osy55 lines
// Sign-in. Two DISJOINT tiers — one ships, one cancels — so switching between them is how you see that neither
// screen offers the other's verb.
using Osyrin.Ui;

[Page("/login")]
[AllowAnonymous]
[Render(CSR)]
[Title("Orders — sign in")]
component LoginPage() {
  string email = "fael@acme.test";
  string password = "demo1234";
  string problem = "";

  action SignIn() {
    var ticket = Login(email, password);
    if (ticket == "") { problem = "That email and password don't match anyone."; }
    else { Session.SignIn(ticket); }
  }

  render {
    Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Bg, color: Colors.OnBg) {
      Stack(gap: 4, w: "100%", maxW: "420px") {

        Stack(gap: 1) {
          Text("Orders", fontFamily: Font.Serif, fontSize: FontSize.Title, fontWeight: FontWeight.Semibold, letterSpacing: "-0.01em");
          Text("One ships, one cancels, and neither can do the other's job.",
               fontSize: FontSize.Body, color: Colors.TextSecondary);
        }

        Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) {
          Stack(gap: 3) {
            Field("Email", value: email, placeholder: "you@acme.test", type: "email");
            Field("Password", value: password, type: "password");
            if (problem != "") {
              Box(bg: Colors.Muted, rounded: Radius.Control, px: 3, py: 2) {
                Text(problem, fontSize: FontSize.Caption, color: Colors.Danger);
              }
            }
            Button("Sign in", onPress: SignIn, tone: Tone.Primary);
          }
        }

        Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
          Stack(gap: 2) {
            Text("Seeded people — password demo1234", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary);
            Text("fael@acme.test — the Fulfillment GRANT: ships, cannot cancel", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("mona@acme.test — the Manager GRANT: cancels, cannot ship", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("rey@acme.test — on the roster, holds neither: can do nothing", fontSize: FontSize.Caption, color: Colors.TextSecondary);
          }
        }
      }
    }
  }
}