Osy#betaRun it on Osyrin: osyrin.com

Samples

wf-supplier-dispatch

wf-supplier-dispatch — CORRELATION: an inbound event finds its run by a BUSINESS KEY.

5 source files1 test file

Get it

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

The app

app.osy10 lines
// wf-supplier-dispatch — CORRELATION: an inbound event finds its run by a BUSINESS KEY.
app WfSupplierDispatch {

  // 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";
}
model/dispatch.osy76 lines
// BASELINE-OUT `BL-3.8` (2026-09-22): naming the workflow runtime's types needs it imported here.
using Osysharp.Workflow;
// DISPATCH — a parcel, and a carrier that has never heard of our workflow.

enum ShipmentStatus { Booked, [Label("In transit")] InTransit, [Label("Out for delivery")] OutForDelivery, Delivered, Lost }

entity Shipment {
  [Required("A parcel is identified by its tracking number — without one the carrier's scans reach nothing."), Unique, MaxLength(60)] string TrackingNumber;

  [Required("Say where the parcel is going."), MaxLength(200)] string Destination;
  ShipmentStatus Status;

  /// Where the carrier last saw it. Written by the workflow from the scan, never by a page.
  [MaxLength(200)] string? LastSeenAt;
  /// How many scans we have accepted for this parcel — the carrier's view of the journey, as we received it.
  int ScanCount;

  security {
    allow read when IsAuthenticated;
    allow create, update when IsAuthenticated;
  }
}

workflow ParcelDispatch {
  Tracks    = Shipment.Status;
  Autostart = true;
  Initial   = Booked;

  [CorrelateOn(this.Item.TrackingNumber == trackingNumber)]
  event Scanned(string trackingNumber, string location, ScanKind kind);

  [Authorize(u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Dispatcher))]
  event MarkLost(string why);

  on MarkLost(string why) { goto Lost; }

  // ⛔ NO ALIAS ON ANY OF THE THREE, AND THAT IS THE DECLARATION. An ALIASED slot is a work item somebody HOLDS —
  //    it appears in an inbox, it can be claimed, and it wants `Candidates`/`Assignee` to say whose it is. A carrier
  //    scan is none of that: it arrives from `Scan` in ingress.osy, which has no principal at all and never will, and
  //    is authenticated by the HMAC on its first line. Naming these `Collection`/`Movement`/`Delivery` read nicely
  //    and said the wrong thing — three unowned work items in everybody's inbox, which `workflow-slot-open-to-
  //    everyone` reported, correctly, three times.
  state Booked {
    subscribe Scanned();
    on Scanned(string trackingNumber, string location, ScanKind kind) {
      this.Item.LastSeenAt = location;
      this.Item.ScanCount = this.Item.ScanCount + 1;
      goto InTransit;
    }
  }

  state InTransit {
    subscribe Scanned();
    on Scanned(string trackingNumber, string location, ScanKind kind) {
      this.Item.LastSeenAt = location;
      this.Item.ScanCount = this.Item.ScanCount + 1;
      if (kind == ScanKind.ArrivedAtFinalDepot) { goto OutForDelivery; }
    }
  }

  state OutForDelivery {
    subscribe Scanned();
    on Scanned(string trackingNumber, string location, ScanKind kind) {
      this.Item.LastSeenAt = location;
      this.Item.ScanCount = this.Item.ScanCount + 1;
      if (kind == ScanKind.Delivered) { goto Delivered; }
    }
  }

  terminal success Delivered { }
  terminal error   Lost { }
}

/// What the carrier's scan MEANS — their vocabulary, not ours, because it arrives in their payload.
enum ScanKind { Collected, [Label("In transit")] InTransit, [Label("Arrived at final depot")] ArrivedAtFinalDepot, Delivered }
model/identity.osy71 lines
// Sign-in for the dispatch desk. The people here are OURS; the carrier is not a user of this app at all, and the
// next file is about why that distinction decides how the carrier gets in.

[Role] enum AppRole { Authenticator, Dispatcher }

[Principal]
entity Operator {
  [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;
  // The carrier posts scans with an `X-API-Key`, and a key is a PER-USER credential: the platform mints one
  // against this row and stores its hash here, so the request runs AS that operator and the rules below govern it
  // exactly as they govern the same person signed in. Two columns, because that is what lets a key be rotated
  // without a gap where neither the old nor the new one works.
  [MaxLength(255)] string? ApiKeyHash;
  [MaxLength(255)] string? ApiKeyHash2;
  security {
    allow read when IsAuthenticated;
    allow read, create, update when IsAuthenticator;
    deny read PasswordHash when !IsAuthenticator;
    // A key hash is a credential and is masked from everyone the way the password is — see `osy docs api-rest`.
    deny read ApiKeyHash when !IsAuthenticator;
    deny read ApiKeyHash2 when !IsAuthenticator;
  }
}

[RoleGrant] entity RoleGrant {
  Operator Grantee;
  AppRole Level;
  security {
    allow read when IsAuthenticated;
    allow read when IsAuthenticator;
    allow create when IsAuthenticator;
  }
}

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

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

[AuthMethod]
string Login(string email, string password) {
  var o = Operator.Where(x => x.Email == email).FirstOrDefault();
  if (o == null) { Security.VerifyPassword(password); return ""; }
  if (Security.VerifyPassword(password, o.PasswordHash)) { return Security.IssueJwt(o.Id, o.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 = [Operator.PasswordHash, Operator.ApiKeyHash, Operator.ApiKeyHash2] },
};

app.AuthBootstrap = new AuthBootstrap {
  Role   = AppRole.Authenticator,
  Login  = Login,
  Signup = Signup,
};
model/ingress.osy65 lines
// THE CARRIER'S DOOR — and the reason it is in THIS file rather than in the platform.
// ⚠ THE VALUE IS INJECTED OUT-OF-BAND AND NEVER COMMITTED — `osy secret set CarrierWebhook` — and this app's
//    TESTS NEED IT. Three of them sign a payload with `Crypto.HmacSha256Hex(Secret.CarrierWebhook, …)`, so on a
//    fresh checkout `osy test` reports three reds that look like a broken app and are a missing value. The runtime
//    says so precisely once you run ("Secret.CarrierWebhook has no value in this application"), but a line here is
//    what a reader sees BEFORE spending a run on it. `osy secret list` names the ones still empty.
using Osysharp.Workflow;

app.Secrets = [ new Secret("CarrierWebhook") ];

/// What the carrier POSTs. THEIR shape, not ours — a webhook contract belongs to whoever sends it.
class CarrierScan {
  [Required("A scan has to name the parcel it is a scan of."), MaxLength(60)]  public string TrackingNumber;
  [Required("A scan has to say where the parcel was seen."), MaxLength(200)] public string Location;
  [Required("A scan has to say what kind of event it is — a depot arrival and a delivery move the parcel differently.")]                 public ScanKind Kind;
  /// The carrier's HMAC-SHA256 over the tracking number and location, keyed with the secret we exchanged at
  /// onboarding. Every real carrier webhook has one of these under some name.
  [Required("An unsigned scan is refused: the HMAC is what makes this the carrier and not somebody guessing tracking numbers."), MaxLength(200)] public string Signature;
}

/// What we answer the carrier. `Accepted` false with `Retry` true is how a sender is told to come back — which is
/// what makes the NoSuchKey/NotRunning distinction below matter rather than being trivia.
class ScanReceipt {
  public bool Accepted;
  public bool Retry;
  [MaxLength(200)] public string Message;
}

/// The endpoint. Anonymous to the app's own identity model — there is no Operator behind a carrier's POST and there
/// never will be — and authenticated on the first line of its own body, by us.
[AllowAnonymous]
ScanReceipt Scan(CarrierScan scan) {
  var expected = Crypto.HmacSha256Hex(Secret.CarrierWebhook, scan.TrackingNumber + "|" + scan.Location);
  if (!Crypto.FixedTimeEquals(expected, scan.Signature)) {
    return new ScanReceipt { Accepted = false, Retry = false, Message = "signature rejected" };
  }

  var outcome = ParcelDispatch.CorrelateScanned(scan.TrackingNumber, scan.Location, scan.Kind);

  if (outcome == CorrelationOutcome.Deposited) {
    return new ScanReceipt { Accepted = true, Retry = false, Message = "recorded" };
  }
  if (outcome == CorrelationOutcome.NoSuchKey) {
    return new ScanReceipt { Accepted = false, Retry = true, Message = "unknown tracking number — try again later" };
  }
  return new ScanReceipt { Accepted = false, Retry = false, Message = "this parcel is no longer in transit" };
}

app.Apis = [
  new RestApi("CarrierApi") {
    Route   = "carrier",
    Version = "1.0",
    // ⛔ `Anonymous = true`, DELIBERATELY — open at the transport, authenticated in the BODY. A carrier has no
    //    account here and never will: there is no key to exchange and no user to be. `ApiKey = true` said the
    //    opposite and refused every scan with 401 BEFORE the handler's own signature check could run. The
    //    authentication that matters is the HMAC-SHA256 on the first line of `Scan`, over (tracking|location),
    //    keyed with the secret exchanged at onboarding and compared with `Crypto.FixedTimeEquals` —
    //    `a_forged_signature_is_rejected` guards it. Open to reach, closed to fool.
    Auth    = new ApiAuth { Anonymous = true },
    Endpoints = [
      new Endpoint(Scan) { Method = HttpMethod.Post, Path = "/scans", SuccessStatus = 200 },
    ],
  },
];
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.
}