Summary#
Work stops at people: somebody has to choose, approve, or say what only they know. PutRequest puts that to them
and keeps it — the question, who asked, what it is about, by when — and the answer comes back to whatever was
waiting:
var request = PutRequest(new NewRequest {
Kind = RequestKind.Approval, To = manager, Title = "Print 400 of the spring poster?",
About = Subject.Of("Proof", proof.Id, "Spring poster", version: "v" + proof.Version),
Within = TimeSpan.FromHours(8), Hours = shopHours,
});
var answer = await Requests.Answer(request); // in a workflow: parks here, for days if it takes days
if (answer.Approved) { UseApproval(request, "v" + proof.Version); Print(proof); }Nothing in it is about agents. A page, a process and an agent ask the same way.
Signature#
use Osysharp.HumanRequests@1; // needs Osysharp.WorkQueues: a request is an item of its queues
policy AsksPeople => IsStaff; // who may put a request (a process asks as the app)
policy OverseesRequests => IsLead; // who reads every request, calls one off, gives it more time
app.Requests = new RequestsSetup { Queue = "Approvals", RemindAfter = TimeSpan.FromHours(4), MaxReminders = 3 };
HumanRequest PutRequest(NewRequest ask)
class NewRequest {
RequestKind Kind; // Question · Approval · Information
string Title; string Context; List<RequestFact> Facts;
Principal? To; string Queue; string OutsideName; string OutsideEmail; // one person · a queue · somebody outside
List<RequestOption> Options; List<RequestField> Fields; // what it may be answered with
Subject? About; Subject? WaitingWork; // what it is about (with its version) · the work that waits for it
TimeSpan? Within; TimeSpan? ExpireAfter; ServiceHours? Hours; // late after · gives up after · counted in
TimeSpan? RemindAfter; TimeSpan? RemindEvery; int? MaxReminders; Principal? EscalateTo;
static NewRequest For<T>(); // a request for the members of class T
}
Task<RequestOutcome> Requests.Answer(HumanRequest request) // wait, in a workflow
RequestOutcome OutcomeOf(HumanRequest request) // read, at any time
class RequestOutcome { RequestStatus Status; string Choice; string Comment; bool Answered; bool Approved; bool Rejected;
string ByName; Assurance? With; DateTime? At; T As<T>(); }
AnswerResult AnswerRequest(HumanRequest request, RequestAnswer answer) // as whoever is signed in
AnswerResult AnswerByLink(AnswerLink link, RequestAnswer answer, string name = "") // through the link
string AnswerLinkFor(HumanRequest request) void RevokeAnswerLink(HumanRequest request)
void UseApproval(HumanRequest request, string versionNow) bool ApprovalCovers(HumanRequest request, string versionNow)
void CancelRequest(HumanRequest request, string why)
void ExtendRequest(HumanRequest request, TimeSpan within, string why, TimeSpan? expireAfter = null)
HumanRequest ReplaceRequest(HumanRequest old, NewRequest ask, string why)
// events: HumanRequestPut · Answered · Reminded · Overdue · Expired · Cancelled · Retimed · AnsweredLate
// screens: RequestSheet(id) · ExternalAnswer(link) · WaitingOnPeople(askedByName, waitingType, waitingId, open)
// slot: RequestSubject(subjectType, subjectId, request, changed) — the kit that owns what a request is about draws itDescription#
What can be asked?#
| Kind | The person sees | The answer |
|---|---|---|
Question | the question and a button per RequestOption — or a box to write in when it offers none | Choice (the option's key), Comment |
Approval | the thing, what a yes covers, Approve and Reject — a no asks why before it sends | Approved / Rejected, Comment (the reason) |
Information | a form: one field per member of the class asked for | As<T>() — the class, filled in |
NewRequest.For<SupplierDetails>() makes the form from the class: a member's name in words is its label, its ///
comment the line under it, its type the kind of field (text, a paragraph, a number, a yes or no, a date, the members
of an enum), and T? means it may be left empty. The answer is checked against the same fields before it is taken,
and read back as the class.
Who is asked, and where do they answer?#
- One person (
To): the request is theirs in the inbox as it arrives ([[workflow-inbox-kit#addressee]]). - Whoever works a queue (no
To): the first of them to answer has answered it. - Somebody with no account (
OutsideEmail): nobody inside answers it.AnswerLinkFor(request)is the text of a link that opens that one request; the app mails it, and gives the page a route:
[On] void MailTheLink(HumanRequestPut e) {
if (e.Request.OutsideEmail != "") { Mail(e.Request.OutsideEmail, $"{App.BaseUrl}/answer/{AnswerLinkFor(e.Request)}"); }
}
[Route("/answer/{link}")] [AllowAnonymous]
component AnswerPage(AnswerLink link) { render { ExternalAnswer(link); } }An answer is written only by the kit's own function, which asks who is answering before it writes: the person asked,
the person it went on to, whoever holds it, the people of its queue, or the holder of its link — and never the
person who asked, even when the request is put to a queue they work themselves. NewRequest.NotBy names one more
person who may not answer it though they work its queue: the person whose own request it decides — a refund they
asked for, put to Finance.
How does the answer come back?#
In a workflow, await Requests.Answer(request) parks the run until the request closes and answers what came back —
the answer, or that it was called off or ran out of time. Put the request in a step, so a body that is run again
finds the one it already put:
var request = Workflow.Once("ask-manager", () => PutRequest(new NewRequest { … }));
var answer = await Requests.Answer(request);Code that cannot wait — a page's action, a plain function — hears HumanRequestAnswered with [On], or reads
OutcomeOf(request) when it looks again. Requests.Answer says so if it is called there (Workflow.InRun (can this code wait?)).
What does an approval cover?#
One exact version, once. An approval is put with what it is of and the version shown (About = Subject.Of(type, id, label, version:) — a revision number, a hash of what was shown; the asker's own terms). The person is told so on
the sheet, before the buttons. The act it allows calls UseApproval(request, versionNow) with the version of the
thing as it is now, in the same unit of work:
UseApproval | |
|---|---|
| approved, the same version, not yet used | marks it used — the act goes on |
| the thing changed after the yes | refused: "… has changed since it was approved, so the approval no longer applies. Ask again, about it as it is now." |
| already used | refused: an approval is used once |
| a no, called off, ran out of time, not answered yet | refused, with which |
ReplaceRequest(old, ask, why) asks again about the thing as it is now and calls the old request off.
What happens when nobody answers?#
Within is how long it may wait before it is late, counted in Hours (ServiceHours (SLA-accrual windows)) — the date the
inbox shows for the item is the same instant. RemindAfter / RemindEvery raise HumanRequestReminded, in working
hours, at most MaxReminders times; the app sends the reminder its own way.
Late is not closed. Past its date the request is overdue: HumanRequestOverdue is raised, it goes on to
EscalateTo when one is named, and it still waits. Nothing is decided by the silence — an approval nobody gave is
not an approval. It closes unanswered only when it was given a time to give up at (ExpireAfter) or is called off
(CancelRequest, by whoever asked or whoever oversees requests). ExtendRequest moves its date and its reminders,
recorded with who and why.
What about an answer that comes too late?#
An answer to a request that already has one, was called off or ran out of time is kept and not applied: a
LateAnswer row beside the request, with who sent it, how they were known and when; HumanRequestAnsweredLate is
raised; the person is told their answer was kept and changes nothing by itself. A link goes on opening its request
after it closed, for exactly this.
What does a person see?#
RequestSheet(id)— the request as whoever is looking may read it. In the inbox it is drawn for you under the item's own head. For the person asked: who asks, what is waiting on the answer, the context, the facts, what it is about as the kit that owns it draws it (RequestSubject), when it is wanted by, and the answer a press away. For whoever asked: who it waits for; then what came back.ExternalAnswer(link)— the page behind a link: the one request, the form, their name, and what became of the answer.WaitingOnPeople(waitingType:, waitingId:)— on the page of the thing that waits: every open request holding it up, of whom, since when, and whether it is late.askedByName:lists what one process or agent has put.- "Approve all" on the inbox's bulk bar while every picked item is an approval — each approved as the person's own answer, so one that is not theirs stays as it was.
Who may do what?#
A request is read by the person it is put to, the person it went on to, whoever holds it, whoever asked, the people
of its queue when it names nobody, and whoever OverseesRequests. It is made only by PutRequest
(AsksPeople); answered only through AnswerRequest / AnswerByLink; an approval's use is marked only by
UseApproval; its time is changed only by ExtendRequest. A link opens the one request it was made for, for that
call, and nothing else in the app.
Examples#
app Printshop {
use Osysharp.HumanRequests;
use Osysharp.WorkQueues;
use Osysharp.Identity;
use Osysharp.Organizations;
use Osysharp.Organizations.ServicePlans;
use Osysharp.Presence;
use Osysharp.Observability;
use Osysharp.Workflow;
use Osysharp.ServiceHours;
use Osysharp.Ui;
model "model/**/*.osy";
}
using Osysharp.Identity;
using Osysharp.Organizations.ServicePlans;
using Osysharp.WorkQueues;
using Osysharp.HumanRequests;
using Osysharp.Workflow;
[Role] enum ShopRole { Authenticator, Printer, Manager }
partial entity RoleGrant { security { allow read when IsAuthenticated; } }
policy IsPrinter => RoleGrant.Any(g => g.Member == user && g.Role == ShopRole.Printer);
policy IsManager => RoleGrant.Any(g => g.Member == user && g.Role == ShopRole.Manager);
// The inbox's questions and the kit's two, answered.
policy WorksInbox => IsPrinter || IsManager;
policy LeadsInbox => IsManager;
policy AdministersInbox => IsManager;
policy ManagesOrganizations => IsManager;
policy CollaboratesOnRecords => IsPrinter || IsManager;
policy AsksPeople => IsPrinter || IsManager;
policy OverseesRequests => IsManager;
entity Person : Principal {
security { allow read when IsAuthenticated || IsAuthenticator; allow create when IsAuthenticator; }
}
partial entity WorkItem { security { allow read when IsPrinter || IsManager; } }
partial entity ServiceHours { security { allow read when IsAuthenticated; } }
partial entity ServiceWindow { security { allow read when IsAuthenticated; } }
partial entity ServiceException { security { allow read when IsAuthenticated; } }
public enum ProofStage { Drafted, WithTheManager, Printed, SentBack }
/// A proof of something to print. Every change to it is a new version.
entity Proof {
[Required("Name the job."), MaxLength(120)] string Job;
int Copies = 100;
int Version = 1;
ProofStage Stage = ProofStage.Drafted;
bool Printed = false;
[MaxLength(400)] string Note = "";
security { allow read, create, update when IsPrinter || IsManager; }
}
/// What the customer is asked for before a job is delivered.
public class DeliveryDetails {
/// Where the boxes go.
public string Address = "";
/// How many boxes we may leave without a signature.
public int UnattendedBoxes;
/// Anything the driver should know.
public string? Notes;
}
public Subject ProofAsShown(Proof p) { return Subject.Of("Proof", p.Id, p.Job, version: "v" + p.Version.ToString()); }
/// Ask the manager to approve a proof as it stands.
public HumanRequest AskToPrint(Proof p, Person manager) {
return PutRequest(new NewRequest {
Kind = RequestKind.Approval, To = manager, Title = "Print " + p.Copies + " of " + p.Job + "?",
Facts = [new RequestFact("Copies", p.Copies.ToString()), new RequestFact("Version", p.Version.ToString())],
About = ProofAsShown(p), WaitingWork = Subject.Of("Proof", p.Id, p.Job),
});
}
/// Print it — covered by the approval of this version, once.
public void Print(Proof p, HumanRequest approval) {
UseApproval(approval, "v" + p.Version.ToString());
p.Printed = true;
}
/// A proof's way to the press: the manager is asked, the process waits, and what it does next is what she said.
workflow ProofFlow {
Tracks = Proof.Stage; Autostart = false; Initial = WithTheManager;
state WithTheManager {
enter {
var request = Workflow.Once("ask", () => AskToPrint(this.Item, Person.Single(x => x.Email == "[email protected]")));
var answer = await Requests.Answer(request);
if (answer.Approved) {
UseApproval(request, "v" + this.Item.Version.ToString());
this.Item.Printed = true;
goto Printed;
}
this.Item.Note = answer.Comment;
goto SentBack;
}
}
terminal success Printed { }
terminal success SentBack { }
}
app.Inbox = new InboxSetup { DefaultQueue = "Shop" };
[TestFixture]
void Shop() {
var noor = new Person { Email = "[email protected]", DisplayName = "Noor" };
var ida = new Person { Email = "[email protected]", DisplayName = "Ida" };
new RoleGrant { Member = noor, Role = ShopRole.Printer };
new RoleGrant { Member = ida, Role = ShopRole.Manager };
new OrganizationTier { Name = "Standard", Rank = 1, IsDefault = true };
var shop = new Queue { Name = "Shop", Position = 1 };
new QueueMember { Queue = shop, Member = noor };
new QueueMember { Queue = shop, Member = ida };
new Proof { Job = "Spring poster", Copies = 400 };
}
principal Noor => Person.Single(p => p.Email == "[email protected]");
principal Ida => Person.Single(p => p.Email == "[email protected]");
Proof Poster() { return Proof.Single(p => p.Job == "Spring poster"); }
[Test(Shop)]
void an_approval_covers_the_version_that_was_approved_and_nothing_it_became() {
Guid id = Guid.Empty;
runas (Noor) { id = AskToPrint(Poster(), Ida).Id; }
runas (Ida) {
Assert.Equal(["Print 400 of Spring poster?"], Requests.Mine().Select(r => r.Title).ToList());
AnswerRequest(Requests.Mine().Single(), RequestAnswer.Approve());
}
runas (Noor) {
// The proof is changed after her yes: 400 copies became 4,000.
var poster = Poster();
poster.Copies = 4000;
poster.Version = poster.Version + 1;
var refused = Assert.Throws<ApprovalNoLongerApplies>(() => Print(Poster(), HumanRequest.Single(r => r.Id == id)));
Assert.Equal("Spring poster has changed since it was approved, so the approval no longer applies. Ask again, about it as it is now.", refused.Message);
Assert.False(Poster().Printed);
}
}
[Test(Shop)]
void whoever_asked_cannot_approve_it_themselves() {
runas (Noor) {
var request = AskToPrint(Poster(), Ida);
var refused = Assert.Throws<RequestRefused>(() => AnswerRequest(request, RequestAnswer.Approve()));
Assert.Equal("You asked this, so it is not yours to answer. It waits for Ida.", refused.Message);
}
}
[Test(Shop)]
void a_process_waits_a_month_for_a_person_and_goes_on_with_the_answer() {
runas (Noor) {
var poster = Poster();
Workflow.Run(poster);
Workflow.Settle(poster);
Assert.Equal(ProofStage.WithTheManager, poster.Stage);
}
TestClock.Advance(TimeSpan.FromDays(30));
runas (Ida) {
var mine = Requests.Mine().Single();
AnswerRequest(mine, RequestAnswer.Reject("The date on it is last year's."));
Workflow.Settle(mine);
}
runas (Noor) {
Workflow.Settle(Poster());
Assert.Equal(ProofStage.SentBack, Poster().Stage);
Assert.Equal("The date on it is last year's.", Poster().Note);
Assert.False(Poster().Printed);
}
}
[Test(Shop)]
void a_request_for_the_members_of_a_class_comes_back_as_the_class() {
Guid id = Guid.Empty;
runas (Noor) {
var ask = NewRequest.For<DeliveryDetails>();
ask.To = Ida;
ask.Title = "Where does the spring poster go?";
var request = PutRequest(ask);
id = request.Id;
var fields = JsonSerializer.Deserialize<List<RequestField>>(request.FieldsJson);
Assert.Equal(["Address", "Unattended boxes", "Notes"], fields.Select(f => f.Label).ToList());
Assert.Equal("Where the boxes go.", fields[0].Help);
}
runas (Ida) {
var missing = Assert.Throws<ValidationException>(() => AnswerRequest(HumanRequest.Single(r => r.Id == id), new RequestAnswer()));
Assert.Equal("“Address” is needed.", missing.Message);
AnswerRequest(HumanRequest.Single(r => r.Id == id), RequestAnswer.Of(new DeliveryDetails { Address = "Dock 4, Varvsgatan 12", UnattendedBoxes = 2 }));
}
runas (Noor) {
var details = OutcomeOf(HumanRequest.Single(r => r.Id == id)).As<DeliveryDetails>();
Assert.Equal("Dock 4, Varvsgatan 12", details.Address);
Assert.Equal(2, details.UnattendedBoxes);
Assert.Null(details.Notes);
}
}Notes#
- Sending is the app's. The kit raises
HumanRequestPutandHumanRequestReminded; whether that becomes a mail, a text message or a bell is the app's[On]handler, with the kit it already uses for it. - A request is put in the caller's unit of work. It exists when that commits — so a request about a record that was never saved is never put. Call a request off in a later request, not in the one that put it.
- Instants are shown as stored (UTC) on the kit's screens until the platform says a viewer's own zone.
- An assignment lists the requests attached to it when the app also uses the bridge
Osysharp.Assignments.WorkQueues.
See also#
- An agent's proposals and questions in a person's inbox, and an inbox item in an agent's hands (Agents.HumanRequests kit) — an agent's proposals and questions as requests; an inbox item in an agent's hands
- What an assignment asks of people — requests as an assignment's work and as inbox items (Assignments.WorkQueues kit) — a request put for an assignment is a piece of its work
- The WorkQueues kit — queues, who holds what, and the service levels promised on any item — the queues a request waits in, and the inbox it is answered from
- Workflow.Run (start a workflow) · Workflow.InRun (can this code wait?) — the wait itself
- grants (a link that opens one row) — the link that opens one row
- Asking for help — an agent that consults a more capable model (Agents kit) — an agent's own ladder of help, whose last rung is a person