Osy#betaRun it on Osyrin: osyrin.com

Reference / Workflow

Give any entity a lifecycle from a kit

[Authority(Extensible)] entity WorkItem { Handling Handling; … } · workflow WorkHandling { Tracks = WorkItem.Handling; Autostart = true; … } · entity Ticket : WorkItem { … } · workflow TicketLife { Tracks = Ticket.Stage; … Responded(this.Item); }

A kit can give an entity the app has not written yet a whole lifecycle: an Extensible base the app's own type derives from, a kit workflow that tracks one field of that base and starts for every subtype, the kit's events as the only way in (with their security on the events), and the engine's deadlines, reminders and service hours as the business rules. The app keeps its own workflow beside the kit's on the same item — one autostart per column, which the compiler enforces. The WorkQueues kit is built this way.

preview1 example compiled by CIworkflowkitpatterninheritance

Summary#

Some behaviour is the same for every app that has it: a support ticket, a purchase request and a job candidacy all wait in a queue, are held by somebody, and owe somebody an answer by a time. A kit can own that behaviour — states, timers, rules and all — for an entity it has never seen, because the app's entity derives from the kit's base:

entity Ticket : WorkItem { [MaxLength(4000)] string Body = ""; … }   // the app's type; the kit's lifecycle runs on it

The kit's workflow tracks a field of WorkItem, starts for every Ticket the moment one is created, and is moved only by the kit's own events. The app's own workflow — the ticket's stages — runs beside it on the same row.

Signature#

// ── in the kit ──
[Authority(Extensible)]                          // the app derives its own types from it
public entity WorkItem {
  Handling Handling;                             // the ONE field the kit's workflow tracks — no default, the run owns it
  Principal? Assignee;                           // …and the facts its rules keep
  security { deny update Assignee, Handling; }   // the floor: app code never writes what the run keeps
}

public workflow WorkHandling {
  Tracks    = WorkItem.Handling;                 // a field of the BASE: the run starts for every subtype's rows
  Autostart = true;
  Initial   = New;
  ServiceHours = this.Item.Hours;                // business rules are the engine's — hours, deadlines, reminders
  Deadline { Within = this.Item.ResolutionWithin; Remind AtRisk(After = Within * 0.75) { … } }
  [Authorize(u => WorksInbox)] event Respond();  // the ways in, each with its own security
  …
}

// The kit's verbs — what the app calls. Each raises one event on the item's own run.
public void Responded(WorkItem item) { WorkHandling.RaiseRespond(item); }

// ── in the app ──
entity Ticket : WorkItem { … }                   // joins the lifecycle by deriving
workflow TicketLife { Tracks = Ticket.Stage; Autostart = true; … }   // its OWN states, beside the kit's

Description#

1. An Extensible base the app derives from#

[Authority(Extensible)] says the app may derive its own types from the kit's entity. The app's type keeps everything it IS — its fields, its states, its own grants (grants are never inherited: the app says who reads a Ticket) — and gains the base's fields, which are what the kit reasons about. The base's denies are the floor every subtype stands on, whatever the app grants: the Inbox's deny update Assignee, Queue, Priority … holds against app code on a Ticket just as on any other kind of work.

2. A kit workflow tracking one field, autostarting per subtype#

A workflow that Tracks a field of the base covers every subtype: it autostarts when a Ticket (or a Spend, or a Candidacy) is committed, and Workflow.WorkByItem<WorkItem>() lists them all on one board while Workflow.WorkByItem<Ticket>() lists only tickets. The tracked field has no default — the run's Initial state is its first value.

3. The kit's events are the only way in#

The app never writes the tracked field or the facts the run keeps; it calls the kit's verbs (Responded(item), WaitOn(item, WaitFor.Requester), TakeWork(item, expected)), each of which raises one event on the item's run. The security is on the EVENT — [Authorize(u => WorksInbox)] event Respond(); — so "only the team says it responded" is a gate on the way in, not a check somebody might skip, and a raise from a workflow body carries the actor who drove it (Raising a workflow event). A run body writes as the system, so it can keep what the base denies to app code.

4. Deadlines, reminders and service hours are the business rules#

The promise is the engine's, not a polling job: the run's Deadline is the resolution clock, a slot's milestones are the response clocks, Remind … (After = Within * 0.75) is "at risk", and ServiceHours = this.Item.Hours makes all of them count working time only, paused in the states that do not accrue. Workflow.Retarget() moves every running clock when the promise changes, keeping the time already used.

5. The app's own workflow, beside the kit's#

An item may carry two runs: the kit's (tracking Handling) and the app's (tracking its own Stage). The app's states call the kit's verbs as they happen — "solved" is WorkDone(this.Item), "pending" is WaitOn(this.Item, WaitFor.Requester) — and declare no clock of their own. One autostart per column: a second workflow that autostarts over the same field anywhere in the hierarchy is refused at compile, naming both, so two machines can never fight over one value.

workflow Rival { Tracks = Ticket.Handling; Autostart = true; … }
// ✗ 'Rival' autostarts over Handling, which WorkHandling (Osysharp.WorkQueues) already autostarts over — one machine per column.

When to reach for this#

When the lifecycle is the same in every app that has it and the app's item differs: inbox work, approvals, privacy requests, invitations, dunning, review and signing. When the states ARE the app's (an order's fulfilment), they stay in the app's own workflow.

Examples#

A ticket with its own stages, on the Inbox's lifecycle#

app Desk {
  use Osysharp.Identity@0;
  use Osysharp.Organizations@0;
  use Osysharp.Organizations.ServicePlans@0;
  use Osysharp.Presence@0;
  use Osysharp.Observability;
  use Osysharp.WorkQueues@1;
  use Osysharp.Workflow;
  use Osysharp.ServiceHours;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.WorkQueues;
using Osysharp.Workflow;

[Role] enum DeskRole { Authenticator, Agent, Admin }
policy WorksInbox => RoleGrant.Any(g => g.Member == user && (g.Role == DeskRole.Agent || g.Role == DeskRole.Admin));
policy LeadsInbox => RoleGrant.Any(g => g.Member == user && g.Role == DeskRole.Admin);
policy AdministersInbox => RoleGrant.Any(g => g.Member == user && g.Role == DeskRole.Admin);
policy ManagesOrganizations => AdministersInbox;
policy CollaboratesOnRecords => WorksInbox;

enum Stage { Open, Pending, Solved }

/// The app's own kind of work — it joins the Inbox's lifecycle by deriving from WorkItem.
entity Ticket : WorkItem {
  [MaxLength(4000)] string Body = "";
  Stage Stage;                                   // no default — TicketLife owns it
  security { allow read, create, update when WorksInbox; }
}

/// The ticket's OWN stages, beside the kit's WorkHandling. It tells the inbox the facts as they happen and keeps no
/// clock of its own: the promise, its hours and its breaches are the kit's.
workflow TicketLife {
  Tracks    = Ticket.Stage;
  Autostart = true;
  Initial   = Open;
  [Authorize(u => WorksInbox)] event AskCustomer();
  [Authorize(u => WorksInbox)] event Solve();
  event CustomerReplied();
  state Open {
    on AskCustomer { Responded(this.Item); goto Pending; }
    on Solve { Responded(this.Item); goto Solved; }
  }
  state Pending {
    enter { WaitOn(this.Item, WaitFor.Requester); }
    on CustomerReplied { RequesterWrote(this.Item); goto Open; }
    on Solve { goto Solved; }
  }
  terminal success Solved { enter { WorkDone(this.Item); } }
}

See also#

Related

The WorkQueues kit — queues, who holds what, and the service levels promised on any item

`Osysharp.WorkQueues` puts any item an app derives from `WorkItem` — a ticket, an approval, a lead — into queues with…

Workflow.WorkByItem&lt;T&gt; (one row per item — the board read)

One row per tracked ENTITY, where `Workflow.Work<T>()` is one per SLOT. A board, a queue and a "my work" screen are all…

ServiceHours (SLA-accrual windows)

A schedule the SLA clock accrues within — the platform WALKS its weekly windows (and holiday exceptions) to advance…

Remind (milestone reminders)

A reminder scheduled off a milestone. Its SCHEDULE is config in the header parens — `After` is the first fire (once, at…

entity Sub : Base

Derives one entity from another. The subtype is its own type with its own name, its own security and its own workflows…

Raising a workflow event

Raise a typed event on the run bound to an entity, from anywhere — an ordinary server function, a webhook handler, a…