Osy#betaRun it on Osyrin: osyrin.com

Samples

wf-expense-hitl

wf-expense-hitl — the SLOT-DEPENDENCY workflow demo (ExpenseApproval).

7 source files2 test files

The wf-expense-hitl sample, running: Expenses waiting on a person
Expenses waiting on a person
The wf-expense-hitl sample, running: The CFO's slot
The CFO's slot

Get it

// not one of the apps the toolchain ships: every file
// of it is below.

The app

app.osy12 lines
// wf-expense-hitl — the SLOT-DEPENDENCY workflow demo (ExpenseApproval).
app WfExpenseHitl {
  use Osysharp.Ui;

  // BASELINE-OUT `BL-3.8` (2026-09-22): the workflow runtime is its own capability now — ten tables
  // that used to be in every app whether or not it declared a `workflow`.
  use Osysharp.Workflow;
  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
  data  "data/**/*.json";
}
model/actions.osy14 lines
// The board's verbs, as ordinary app functions.

void DecideManager(Expense e, Decision decision) { ExpenseApproval.For(e).Manager.Approve(decision); }

void DecideFinance(Expense e, Decision decision) { ExpenseApproval.For(e).Finance.Approve(decision); }

void DecideCfo(Expense e, Decision decision) { ExpenseApproval.For(e).Cfo.Approve(decision); }

void RaiseExpense(decimal amount) {
  var me = Session.CurrentUser;
  new Expense { Employee = me, Amount = amount };
  UnitOfWork.Commit();
}
model/expense_approval.osy67 lines
// BASELINE-OUT `BL-3.8` (2026-09-22): naming the workflow runtime's types needs it imported here.
using Osysharp.Workflow;
// EXPENSE APPROVAL — slot dependencies (`After` / `When`). Manager + Finance open in parallel; the CFO slot opens ONLY
// after both are satisfied, and only exists for large expenses. Faithful to workflow_design/approvals.osy.
enum Decision      { Approve, Reject }
enum ExpenseStatus { Approvals, Approved, Rejected }

entity Expense {
  [Required("An expense is claimed by somebody — the approval chain routes back to them.")] User Employee;
  decimal Amount;
  ExpenseStatus Status;
  User RejectedBy;
  [ForeignKey(Expense)] Approval[] Approvals;
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

entity Approval {
  [Required("An approval is an approval OF one expense.")] Expense Expense;
  [Required("Record who approved it — an unsigned approval proves nothing.")] User By;
  DateTime At;
  security { allow read when IsAuthenticated; allow create when IsAuthenticated; }
}

workflow ExpenseApproval {
  Tracks    = Expense.Status;
  Autostart = true;
  Initial   = Approvals;

  event Approve(Decision decision);

  state Approvals {
    subscribe Approve(Decision decision) as Manager {
      Assignee = this.Item.Employee.Manager;
      Finished { Within = TimeSpan.FromDays(2);  Unfinished { goto Rejected; } }
    }

    subscribe Approve(Decision decision) as Finance {
      Candidates = u => u.Department == Dept.Finance;
      Assigned   { Within = TimeSpan.FromHours(4); }
      Finished   { Within = TimeSpan.FromDays(2);  Unfinished { goto Rejected; } }
    }

    subscribe Approve(Decision decision) as Cfo {
      When       = this.Item.Amount > 10000;
      After      = [Manager, Finance];
      Candidates = u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Cfo)
                        && u != Manager.Assignee;
      Finished   { Within = TimeSpan.FromDays(3);  Unfinished { goto Rejected; } }
    }

    on Approve(Decision decision, Slot slot) {
      when (decision == Decision.Reject) {
        this.Item.RejectedBy = slot.Assignee;
        goto Rejected;                                  // any rejection ends it — the CFO never opens
      }
      default {
        new Approval { Expense = this.Item, By = slot.Assignee, At = DurableClock.Now };
      }
    }

    on Complete { goto Approved; }        // every slot that EXISTS is satisfied
  }

  terminal success Approved { }
  terminal error   Rejected { Message = "expense rejected"; }
}
model/identity.osy70 lines
// The demo's identity model — WHO the people are, and on what authority.

// ⚠ `Executive` exists so the CFO has somewhere to WORK that is not the pool she signs off. A department is where
// you work; the office is the grant.
enum Dept { Legal, Finance, Engineering, Executive }

[Role] enum AppRole { Authenticator, Member, Cfo }

[Principal]
entity User {
  [Required("Tell us what to call you."), MaxLength(100)] string Name;
  [Required("We need an address to sign you in at."), MaxLength(200), Unique] string Email;
  [MaxLength(200)] string? PasswordHash;
  Dept Department = Dept.Legal;
  User Manager;
  security {
    allow read when IsAuthenticated;
    allow read, create when IsAuthenticator;
    allow create when IsCfo;
    allow update when IsCfo;
    deny read PasswordHash when !IsAuthenticator;   // nobody but the auth flow ever sees the hash
  }
}

[RoleGrant] entity RoleGrant {
  [Required("A grant has to name who it is for.")] User Grantee;
  AppRole Level = AppRole.Member;
  security {
    allow read when IsAuthenticated;
    allow create when IsAuthenticator;              // …the signup, for the first grant
    allow create, update, delete when IsCfo;        // all three verbs, or the weakest is the way in
  }
}

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

[AuthMethod]
string Signup(string email, string password) {
  if (password.Length < 8) { return ""; }   // hashing is not a length check: "" and "a" hash perfectly well
  var u = new User { Name = email, Email = email, PasswordHash = Security.HashPassword(password) };
  var grant = new RoleGrant { Grantee = u, Level = AppRole.Member };
  return Security.IssueJwt(u.Id, u.Email);
}

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

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

// Audit redaction is EXPLICIT. The entity-change trail records every changed property's old AND new value verbatim
// into a durable, exportable log, and the `deny read` on the credential does not cover it — that governs READERS,
// and the audit writer is not one. Named here, the trail records THAT the field changed and never its value.
app.Audit = new AuditConfig {
  Redact = new AuditRedaction { Properties = [User.PasswordHash] },
};

app.AuthBootstrap = new AuthBootstrap {
  Role      = AppRole.Authenticator,
  Login     = Login,
  Signup    = Signup,
  LoginPage = LoginPage,
};
model/theme.osy73 lines
// THE SHARED DEMO THEME — canonical copy. Every demo listed in `DemoSharedThemeTests` holds a
// byte-identical copy as its theme (`model/look/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 `Osysharp.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: "#636B75", dark: "#8A94A0");   // the quietest text on the page — still WCAG AA (4.5:1) on Bg, Surface and Muted: #7C848E read 3.3–3.8:1, and the kit draws every field label in it (the UX sweep, test:visual)

    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.osy149 lines
// THE BOARD — and what it has to show is not "an expense", it is WHICH SLOT IS OPEN TO WHOM, and which one exists
// but is not open yet. That last state is what this demo is for: a `Cfo` slot waiting on `After = [Manager, Finance]`
// is visible and PENDING, with no clock running, and it does not exist at all below the `When` threshold.

using Osysharp.Workflow;

[Route("/")]
[Render(CSR)]
[Title("Expense approvals")]
component Board() {
  live var work = Workflow.Work<Expense>()
                          .Include(r => r.Item)
                          .Include(r => r.Item.Employee)
                          .OrderBy(r => r.Remaining);

  live var mine = Workflow.Inbox<Expense>();

  live var expenses = Expense.Include(e => e.Employee).OrderByDescending(e => e.Amount).Take(200).ToList();

  live var people = User.Take(200).ToList();

  var me = Session.CurrentUser;

  decimal amount = 50000;

  action DecideAsManager(Expense e, Decision d) { DecideManager(e, d); }
  action DecideAsFinance(Expense e, Decision d) { DecideFinance(e, d); }
  action DecideAsCfo(Expense e, Decision d)     { DecideCfo(e, d); }
  action Raise()   { RaiseExpense(amount); }
  action SignOut() { Session.SignOut(); }

  // ⚑ WHAT THE PAGE LOOKS LIKE WHILE `work`, `mine`, `expenses` and `people` ARE STILL ON THEIR WAY. They are server reads, so
  //    without this the page paints nothing until they answer — and a blank screen is indistinguishable from one
  //    that hung. It draws the page's SHAPE, never its data: it cannot read the members it stands in for.
  skeleton {
    Stack(gap: 0, minH: "100vh", bg: Colors.Bg) {
      Row(justify: Justify.Center, w: "100%", bg: Colors.Surface, borderW: 1, border: Colors.Border) {
        Row(align: Align.Center, w: "100%", maxW: "980px", px: 5, h: "60px") { Skeleton(shape: SkeletonShape.Control); }
      }
      Row(justify: Justify.Center, align: Align.Start, grow: 1) {
        Stack(gap: 5, w: "100%", maxW: "980px", p: 5) {
          Skeleton(shape: SkeletonShape.HalfLine);
          Skeleton(shape: SkeletonShape.Block);
          Skeleton(shape: SkeletonShape.Block);
        }
      }
    }
  }

  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.Between, w: "100%", maxW: "980px", px: 5, h: "60px") {
          Text("Expense approvals", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
          Row(align: Align.Center, gap: 3) {
            Text(me.Name + " · " + me.Department.Label, fontSize: FontSize.Caption, color: Colors.TextSecondary);
            if (IsCfo) { Text("CFO office", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success); }
            Button("Sign out", onPress: SignOut, tone: Tone.Ghost, size: Size.Sm);
          }
        }
      }

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

          Stack(gap: 2) {
            Text("Approval slots", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold);
            Text("Every slot on every expense — open, pending, and who each one belongs to.",
                 fontSize: FontSize.Caption, color: Colors.TextSecondary);

            if (work.Count() == 0) {
              Text("Nothing is waiting. Raise an expense below to start a chain.",
                   fontSize: FontSize.Caption, color: Colors.TextSecondary);
            }

            foreach (var r in work) {
              Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
                Row(justify: Justify.Between, align: Align.Center, gap: 4) {

                  Stack(gap: 1) {
                    Text(r.Item.Employee.Name + "'s expense · " + r.SlotAlias,
                         fontSize: FontSize.Body, fontWeight: FontWeight.Semibold);
                    Text(r.Item.Amount + " · " + r.Status, fontSize: FontSize.Caption, color: Colors.TextSecondary);
                  }

                  if (r.Status == SlotStatus.Pending) {
                    Text("pending — waiting on manager + finance",
                         fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Warning);
                  } else if (mine.Any(m => m.SlotId == r.SlotId)) {
                    Row(gap: 2) {
                      if (r.SlotAlias == "Manager") {
                        Button("Approve", onPress: () => DecideAsManager(r.Item, Decision.Approve), tone: Tone.Success);
                        Button("Reject", onPress: () => DecideAsManager(r.Item, Decision.Reject), tone: Tone.Danger);
                      }
                      if (r.SlotAlias == "Finance") {
                        Button("Approve", onPress: () => DecideAsFinance(r.Item, Decision.Approve), tone: Tone.Success);
                        Button("Reject", onPress: () => DecideAsFinance(r.Item, Decision.Reject), tone: Tone.Danger);
                      }
                      if (r.SlotAlias == "Cfo") {
                        Button("Approve", onPress: () => DecideAsCfo(r.Item, Decision.Approve), tone: Tone.Success);
                        Button("Reject", onPress: () => DecideAsCfo(r.Item, Decision.Reject), tone: Tone.Danger);
                      }
                    }
                  } else if (r.Assignee != null) {
                    Text("with " + people.Single(u => u.Id == r.Assignee).Name,
                         fontSize: FontSize.Caption, color: Colors.TextSecondary);
                  } else {
                    Text("open — not yours", fontSize: FontSize.Caption, color: Colors.TextSecondary);
                  }
                }
              }
            }
          }

          Stack(gap: 2) {
            Text("All expenses", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
            foreach (var e in expenses) {
              Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
                Stack(gap: 1) {
                  Text(e.Employee.Name + " · " + e.Amount, fontSize: FontSize.Body, fontWeight: FontWeight.Medium);
                  Text(e.Status.Label + (e.Amount > 10000 ? " · needs a CFO" : " · under the CFO threshold"),
                       fontSize: FontSize.Caption, color: Colors.TextSecondary);
                }
              }
            }
          }

          Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
            Stack(gap: 3) {
              Text("Raise an expense", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
              Text("Over 10,000 grows a CFO slot that does not exist below it — that threshold is the slot's `When`.",
                   fontSize: FontSize.Caption, color: Colors.TextSecondary);
              Row(gap: 3, align: Align.End) {
                // `Field` DRAWS the label it is given, wires it to the box, and carries the hint/error row under
                // it. The atom underneath draws none of that: `Input(label: "Amount")` is the ACCESSIBLE name only, so
                // the hand-rolled `Text` above it was the one visible caption — and a caption is not a label,
                // because clicking it does not focus the box.
                DecimalField("Amount", value: amount, min: 0m, placeholder: "50000");
                Button("Raise", onPress: Raise, tone: Tone.Primary);
              }
            }
          }
        }
      }
    }
  }
}
model/pages/login.osy57 lines
// Sign-in. Four people, three different reasons to be able to act — sign in as each and the board is a different
// board. That IS the demo, so being able to switch quickly is the page's only job.

[Route("/login")]
[AllowAnonymous]
[Render(CSR)]
[Title("Expenses — sign in")]
component LoginPage() {
  string email = "[email protected]";
  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("Expense approvals", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold, letterSpacing: "-0.02em");
          Text("A manager, finance and — over 10,000 — the CFO. Sign in as each to see the chain.",
               fontSize: FontSize.Body, color: Colors.TextSecondary);
        }

        Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) {
          Stack(gap: 3) {
            // `Field` DRAWS the label it is given and wires it to the box. The `Text` above the atom only PAINTED the
            // word — clicking it focused nothing — and the atom's own `label:` is the accessible name, drawn nowhere.
            Field("Email", value: email, placeholder: "[email protected]");
            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("[email protected] — Liam's MANAGER (an org relation)", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("[email protected] — Finance DEPARTMENT (an org attribute)", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("[email protected] — holds the Cfo GRANT (an authority tier)", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("[email protected] — the employee; raises expenses, approves none", fontSize: FontSize.Caption, color: Colors.TextSecondary);
          }
        }
      }
    }
  }
}