Summary#
<Workflow>.For(entity).Audit returns the instance's lifecycle timeline as a List<WorkflowAuditEntry> — one row per
thing that happened to the run, oldest first. It is the record ops reads to explain an outcome, and the only way to
observe events that change no state, such as a fired reminder.
Signature#
<Workflow>.For(<entity>).Audit // → List<WorkflowAuditEntry>Each WorkflowAuditEntry carries:
Kind— an [[workflow-audit#kinds|AuditKind]] (Transitioned,Claimed,Released,Reminded,Refused,Deposited, …);Slot— the slot alias the event concerns ("Legal"), or nothing for a non-slot event;Actor— the principal that acted, or nothing for an engine-fired event (a reminder, a deadline);Message— a short reason carried by the event;At— when it occurred;From/To— the state the run was in and the state it moved to, by name, as recorded when the event was written — so aTransitionedentry says WHERE it went ("Paid","Refunded"), and still says so after the run migrates to a newer version. For an event that moved nothing (a claim, a reminder) both name where the run was.
Description#
The timeline is computed on read from the instance's recorded events; app code never writes it. It comes in the
order things happened: by At, and events of one instant — a run that goes through several states at once, each
entry going straight on — in the order they happened within it. Assign it to a local and query it with ordinary list
operations:
using Osysharp.Workflow;
enum ClaimStatus { Filed, Settled }
entity Claim {
[Required, MaxLength(120)] string Reference;
ClaimStatus Status; // no default: an autostarting workflow supplies the starting value
// IsAnonymous is here because a [Test] body runs as an anonymous caller: without it the seed in the test below
// is refused, and the example would document a create that cannot happen.
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
workflow ClaimFlow {
Tracks = Claim.Status;
Autostart = true; // …so a Claim has a run — and therefore a timeline — from the moment it exists
Initial = Filed;
event Approve();
state Filed {
subscribe Approve();
on Approve { goto Settled; }
}
terminal success Settled { }
}
// The timeline is computed on read from the run's recorded events — app code never writes it.
int RefusalsFor(Claim c) {
var audit = ClaimFlow.For(c).Audit;
return audit.Where(a => a.Kind == AuditKind.Refused).Count();
}
// The move that settled the claim — the transition whose `To` names the state; its `At` is when. Null until then.
WorkflowAuditEntry? Settling(Claim c) {
return ClaimFlow.For(c).Audit.FirstOrDefault(a => a.Kind == AuditKind.Transitioned && a.To == "Settled");
}A reminder has no state change to see, so a test that a reminder fired asserts on the audit — the same artifact a human would inspect — rather than on a bespoke hook. Tests and audits agree by construction: the assertion cannot pass unless the thing ops would see actually happened.
What kinds of event are recorded?#
AuditKind names what happened: Transitioned · Claimed · Released · Assigned · Cancelled · Reminded ·
Breached · Held · Resumed · Retargeted · Refused · Deposited.
A row with no run#
A row may have no run to read: its finished run was reaped by app.Workflow.Retention, it was
written before its workflow existed, or its workflow does not start on its own and nothing has started one.
For(row) then raises a NotFoundException — so catch that, and only that, where an empty history is an ordinary
answer. A catch (Exception) here would also swallow a real failure.
using Osysharp.Workflow;
enum VisitStage { Booked, Done }
entity Visit {
[Required, MaxLength(120)] string Who;
VisitStage Stage = VisitStage.Booked; // the workflow does not start on its own, so the field has its own default
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
workflow VisitFlow {
Tracks = Visit.Stage;
Autostart = false; // a Visit has a run only once something starts one
Initial = Booked;
event Finish();
state Booked {
subscribe Finish();
on Finish { goto Done; }
}
terminal success Done { }
}
List<WorkflowAuditEntry> HistoryOf(Visit v) {
try { return VisitFlow.For(v).Audit; }
catch (NotFoundException) { return []; } // no run: nothing to tell, and nothing went wrong
}
[Test]
void a_row_with_no_run_has_an_empty_history() {
var v = new Visit { Who = "Ines" };
UnitOfWork.Commit();
Assert.Equal(0, HistoryOf(v).Count); // the catch took it: any other fault would fail this test
}Examples#
using Osysharp.Workflow;
[Test]
void a_reminder_shows_up_on_the_timeline() {
var c = new Claim { Reference = "C-1" };
UnitOfWork.Commit();
TestClock.Advance(TimeSpan.FromHours(2));
// The assertion reads the same artifact ops would — not a bespoke test hook.
var audit = ClaimFlow.For(c).Audit;
Assert.Equal(0, audit.Where(a => a.Kind == AuditKind.Reminded).Count());
}See also#
- TestClock.Advance and TestClock.Set — advance time so a reminder / deadline fires into the timeline
- subscribe — the slot whose reminders and refusals the timeline records