wf-fanout-quorum
wf-fanout-quorum — the FAN-OUT + QUORUM workflow demo.
8 source files3 test files

Get it
// not one of the apps the toolchain ships: this one lives in the // repository. Clone it, then: $ cd demo/wf-fanout-quorum $ osy launch
The app
app.osy8 lines
// wf-fanout-quorum — the FAN-OUT + QUORUM workflow demo. app WfFanoutQuorum { use Osyrin.Ui; model "model/**/*.osy"; tests "tests/**/*.test.osy"; data "data/**/*.json"; }
model/actions.osy24 lines
// The boards' verbs, as ordinary app functions. // ⚑ DESIGN REVIEW deposits by the slot's PER-INSTANCE alias. The fan-out is over a LITERAL enum list, so each // instance has a static name — `.Architect`, `.Security`, `.Product` — and the caller says which hat they are voting // as. Voting is not the same as being eligible: the deposit is refused unless the acting principal holds that grant. void VoteAsArchitect(Design d, Decision decision, string comment) { DesignReview.For(d).Architect.Vote(decision, comment); } void VoteAsSecurity(Design d, Decision decision, string comment) { DesignReview.For(d).Security.Vote(decision, comment); } void VoteAsProduct(Design d, Decision decision, string comment) { DesignReview.For(d).Product.Vote(decision, comment); } void SubmitReview(Paper p, Decision decision) { PeerReview.For(p).Referees.Review(decision); } void RaiseDesign(string title) { new Design { Title = title }; UnitOfWork.Commit(); }
model/design_review.osy56 lines
// DESIGN REVIEW — N-of-M rebuilt on `Requires` (the naked count is gone). Three symmetric voters; the state does not // advance until a quorum of APPROVALS holds. Casting a vote always works — it just gets RECORDED as data. // (Design reasoning: workflow_design/approvals.osy, the DesignReview block + #9.) enum Decision { Approve, Reject } enum DesignStatus { Gathering, Accepted, Rejected } entity Design { [Required, MaxLength(200)] string Title; DesignStatus Status; [ForeignKey(Design)] Vote[] Votes; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } /// Votes are DATA — auditable, queryable, displayable. Not engine internals. entity Vote { [Required] Design Design; [Required] User Voter; Decision Decision = Decision.Approve; [MaxLength(500)] string Comment; security { allow read when IsAuthenticated; allow create when IsAuthenticated; } } workflow DesignReview { Tracks = Design.Status; Autostart = true; Initial = Gathering; event Vote(Decision decision, string comment); state Gathering { Expire = TimeSpan.FromDays(7); subscribe Vote(Decision decision, string comment) as Voters foreach (AppRole r in [AppRole.Architect, AppRole.Security, AppRole.Product]) { Candidates = u => RoleGrant.Any(g => g.Grantee == u && g.Level == r); } on Vote(Decision decision, string comment, Slot slot) { new Vote { Design = this.Item, Voter = slot.Assignee, Decision = decision, Comment = comment }; when (this.Item.Votes.Count(v => v.Decision == Decision.Reject) >= 2) { goto Rejected; } } Requires { Quorum { Must = this.Item.Votes.Count(v => v.Decision == Decision.Approve) >= 2; Message = "Two of three approvals are required."; } } on Complete { goto Accepted; } // fires when the REQUIREMENTS hold — the third never has to vote on Expire { goto Rejected; } } terminal success Accepted { } terminal error Rejected { Message = "design rejected"; } }
model/identity.osy57 lines
// The demo's identity model — WHO the people are, and on what authority. [Role] enum AppRole { Authenticator, Architect, Security, Product } [Principal] entity User { [Required, MaxLength(100)] string Name; [Required, MaxLength(200), Unique] string Email; [MaxLength(200)] string? PasswordHash; bool OnLeave; Topic? ReviewTopic; security { allow read when IsAuthenticated; allow read, create when IsAuthenticator; allow create when IsArchitect; allow update when IsArchitect; deny read PasswordHash when !IsAuthenticator; // nobody but the auth flow ever sees the hash } } entity RoleGrant { [Required] User Grantee; [Required] AppRole Level = AppRole.Architect; security { allow read when IsAuthenticated; allow create when IsAuthenticator; // …the signup, for the first grant allow create, update, delete when IsArchitect; // all three verbs, or the weakest is the way in } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); policy IsArchitect => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Architect); [AuthMethod] string Signup(string email, string password) { var u = new User { Name = email, Email = email, PasswordHash = Security.HashPassword(password) }; 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 }; app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup, LoginPage = LoginPage, };
model/peer_review.osy71 lines
// PEER REVIEW — a DYNAMIC fan-out. Where DesignReview fans out over a LITERAL enum list (compile-time, one slot per // member), PeerReview fans out over a RUNTIME entity collection (`this.Item.Topic.Referees`): one slot per referee, // resolved at STATE ENTRY, each PRE-ASSIGNED to its own referee. Referees aren't known until run time, so a slot is // addressed by the ACTING PRINCIPAL (its base alias `Referees`), not by a static per-instance name. The quorum is 3 // approving Reviews (state-level `Requires`, counting DATA rows — the naked count is gone). // (Design reasoning: workflow_design/approvals.osy, the PeerReview block + W39.) enum PaperStatus { Refereeing, Decided, Withdrawn } entity Topic { [Required, MaxLength(200)] string Name; [ForeignKey(ReviewTopic)] User[] Referees; // the users refereeing this topic — the dynamic fan-out collection [Required] User BackupReferee; // the fallback pool member, admitted by every slot's Candidates security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } entity Paper { [Required, MaxLength(200)] string Title; [Required] Topic Topic; PaperStatus Status; [ForeignKey(Paper)] Review[] Reviews; security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; } } /// Reviews are DATA — auditable, queryable, counted by the quorum. Not engine internals. entity Review { [Required] Paper Paper; [Required] User Referee; Decision Decision = Decision.Approve; security { allow read when IsAuthenticated; allow create when IsAuthenticated; } } void Notify(User u) { Log.Information($"peer-review reminder — nudging {u.Id}"); } workflow PeerReview { Tracks = Paper.Status; Autostart = true; Initial = Refereeing; event Review(Decision decision); state Refereeing { Expire = TimeSpan.FromDays(60); subscribe Review(Decision decision) as Referees foreach (User u in this.Item.Topic.Referees) { Assignee = u; Candidates = c => c == u || c == this.Item.Topic.BackupReferee; // the fallback pool Finished { Within = TimeSpan.FromDays(14); Remind Nudge(After = TimeSpan.FromDays(7)) { Notify(slot.Assignee); } Unfinished(Slot slot) { slot.Assign(this.Item.Topic.BackupReferee); } // no goto → keep waiting } } on Review(Decision decision, Slot slot) { new Review { Paper = this.Item, Referee = slot.Assignee, Decision = decision }; } Requires { Quorum { Must = this.Item.Reviews.Count(r => r.Decision == Decision.Approve) >= 3; Message = "Three approving reviews are required."; } } on Complete { goto Decided; } // fires when the REQUIREMENTS hold — the rest never have to review on Expire { goto Withdrawn; } } terminal success Decided { } terminal cancel Withdrawn { Message = "not enough referees responded"; } }
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.osy161 lines
// THE BOARD — and what it has to show is a COUNT, not a gate. Both workflows here advance on a quorum: two of three // design votes, three approving peer reviews. using Osyrin.Ui; [Page("/")] [Render(CSR)] [Title("Design & peer review")] component Board() { live var designs = Design.Include(d => d.Votes).OrderByDescending(d => d.CreatedAt).ToList(); live var papers = Paper.Include(p => p.Reviews).Include(p => p.Topic).OrderByDescending(p => p.CreatedAt).ToList(); live var mineDesigns = Workflow.Inbox<Design>().Include(r => r.Item); live var minePapers = Workflow.Inbox<Paper>().Include(r => r.Item); var me = Session.CurrentUser; string title = "Sharding the ledger"; action VoteArchitect(Design d) { VoteAsArchitect(d, Decision.Approve, "lgtm"); } action VoteSecurity(Design d) { VoteAsSecurity(d, Decision.Approve, "ok"); } action VoteProduct(Design d) { VoteAsProduct(d, Decision.Approve, "ship it"); } action RejectArchitect(Design d) { VoteAsArchitect(d, Decision.Reject, "needs work"); } action RejectSecurity(Design d) { VoteAsSecurity(d, Decision.Reject, "unsafe"); } action RejectProduct(Design d) { VoteAsProduct(d, Decision.Reject, "off-roadmap"); } action Review(Paper p) { SubmitReview(p, Decision.Approve); } action RejectPaper(Paper p) { SubmitReview(p, Decision.Reject); } action Raise() { RaiseDesign(title); } 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("Design & peer review", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Row(align: Align.Center, gap: 3) { Text(me.Name, 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("Designs", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold); Text("Two of three approvals accept a design. The third never has to vote.", fontSize: FontSize.Caption, color: Colors.TextSecondary); foreach (var d in designs) { 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(d.Title, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold); Text(d.Status, fontSize: FontSize.Caption, color: Colors.TextSecondary); } if (d.Votes.Count(v => v.Decision == Decision.Approve) >= 2) { Text(d.Votes.Count(v => v.Decision == Decision.Approve) + " of 3 approved", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success); } else { Text(d.Votes.Count(v => v.Decision == Decision.Approve) + " of 3 approved — need 2", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Warning); } } if (d.Votes.Count() > 0) { Stack(gap: 1) { foreach (var v in d.Votes) { Text(v.Voter.Name + " — " + v.Decision + (v.Comment != "" ? " · " + v.Comment : ""), fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } foreach (var r in mineDesigns) { if (r.Item == d) { Row(gap: 2, align: Align.Center) { Text("vote as " + r.SlotAlias, fontSize: FontSize.Caption, color: Colors.TextSecondary); if (r.SlotAlias == "Architect") { Button("Approve", onPress: () => VoteArchitect(d), tone: Tone.Success); Button("Reject", onPress: () => RejectArchitect(d), tone: Tone.Danger); } if (r.SlotAlias == "Security") { Button("Approve", onPress: () => VoteSecurity(d), tone: Tone.Success); Button("Reject", onPress: () => RejectSecurity(d), tone: Tone.Danger); } if (r.SlotAlias == "Product") { Button("Approve", onPress: () => VoteProduct(d), tone: Tone.Success); Button("Reject", onPress: () => RejectProduct(d), tone: Tone.Danger); } } } } } } } } Stack(gap: 2) { Text("Papers", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Text("One slot per referee on the topic, resolved at run time. Three approvals decide it.", fontSize: FontSize.Caption, color: Colors.TextSecondary); foreach (var p in papers) { 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(p.Title, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold); Text(p.Topic.Name + " · " + p.Status, fontSize: FontSize.Caption, color: Colors.TextSecondary); } if (p.Reviews.Count(x => x.Decision == Decision.Approve) >= 3) { Text(p.Reviews.Count(x => x.Decision == Decision.Approve) + " of 3 approved", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success); } else { Text(p.Reviews.Count(x => x.Decision == Decision.Approve) + " of 3 approved — need 3", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Warning); } } if (p.Reviews.Count() > 0) { Stack(gap: 1) { foreach (var rv in p.Reviews) { Text(rv.Referee.Name + " — " + rv.Decision, fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } foreach (var r in minePapers) { if (r.Item == p) { Row(gap: 2) { Button("Approve", onPress: () => Review(p), tone: Tone.Success); Button("Reject", onPress: () => RejectPaper(p), tone: Tone.Danger); } } } } } } } Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Text("Raise a design", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold); Row(gap: 3, align: Align.End) { Stack(gap: 1, grow: 1) { Text("Title", fontSize: FontSize.Caption, color: Colors.TextSecondary); Input(value: title, label: "Title", placeholder: "What is being reviewed"); } Button("Raise", onPress: Raise, tone: Tone.Primary); } } } } } } } }
model/pages/login.osy63 lines
// Sign-in. Two ways of being eligible sit side by side here — a GRANTED reviewer hat (design review) and a RELATION // to a topic (peer review) — so switching people quickly is the page's only job. using Osyrin.Ui; [Page("/login")] [AllowAnonymous] [Render(CSR)] [Title("Reviews — sign in")] component LoginPage() { string email = "ada@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("Design & peer review", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold, letterSpacing: "-0.02em"); Text("Two of three, and three of many. Sign in as each to see whose vote is still missing.", fontSize: FontSize.Body, color: Colors.TextSecondary); } Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) { Stack(gap: 3) { Stack(gap: 1) { Text("Email", labelFor: emailBox, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary); Input(value: email, id: emailBox, placeholder: "you@acme.test"); } Stack(gap: 1) { Text("Password", labelFor: passwordBox, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary); Input(value: password, id: passwordBox, 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("ada@acme.test — the Architect GRANT (a design-review hat)", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("sam@acme.test — the Security GRANT", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("pat@acme.test — the Product GRANT", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("ravi@acme.test — referees the topic (a RELATION, not a hat)", fontSize: FontSize.Caption, color: Colors.TextSecondary); Text("bo@acme.test — the backup referee, in every referee slot's pool", fontSize: FontSize.Caption, color: Colors.TextSecondary); } } } } } }