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 itThe 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'sDescription#
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#
- The WorkQueues kit — queues, who holds what, and the service levels promised on any item — the Inbox, built this way: queues, holders, promises over any
WorkItem - entity Sub : Base — Extensible bases, subtypes, and "a workflow over a base covers its subtypes"
- Workflow.WorkByItem<T> (one row per item — the board read) — one board across an item's runs
- ServiceHours (SLA-accrual windows) · Remind (milestone reminders) — the business rules the engine keeps
- Raising a workflow event — raising a kit's event from a body, and who the actor is