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. }