Osy#betaRun it on Osyrin: osyrin.com

Reference / Workflow

Assigned / Finished (milestones)

Assigned { Within = …; Unassigned { … } } · Finished { Within = …; Unfinished { … goto … } }

A milestone puts an SLA on a slot's progress — Assigned (someone must PICK IT UP within Within) and Finished (it must be SATISFIED within Within). Each carries a breach arm — Unassigned / Unfinished — that runs when the SLA lapses, and inside that arm the ambient `slot` is the slot the milestone hangs off: `slot.Assign(p)`, `slot.Candidates(u)`, `slot.Assignee`.

stable1 example compiled by CIworkflowauthoring

Summary#

A milestone puts a deadline on a slot's progress. There are two, by the two things that can be late: Assigned — the slot must be picked up (claimed or pre-assigned) within Within — and Finished — the slot must be satisfied (deposited into) within Within. Each has a breach arm that runs when its SLA lapses: Unassigned { … } for Assigned, Unfinished { … } for Finished. A breach is not a failure by itself: an arm with no goto runs its body and the wait lives on; an arm that gotos ends the wait.

Signature#

Assigned {
  Within = <TimeSpan>;         // must be picked up within this
  Unassigned { <body> }        // ran if still Unassigned at Within (may `goto`)
}
Finished {
  Within = <TimeSpan>;         // must be satisfied within this
  Unfinished { <body> }        // ran if not Satisfied at Within (may `goto`)
}

Description#

A milestone is declared where its SLA belongs — on a slot (inside its subscribe … { }), on a state, or on the workflow as a cascade default; nearest declaration wins (slot over state over workflow). A cascade default is inherited only by pool slots — a slot that declares Candidates — since the SLA models pool pickup (Assigned) and completion (Finished) and its body reads slot.Candidates; a bare system-event slot (subscribe Submit();) is not SLA-tracked. A slot may override just one clause (e.g. Finished { Within = TimeSpan.FromDays(2); }) and inherit the rest. Within is a TimeSpan expression over this.Item, so different instances can carry different SLAs (snapshot them in Start { }).

When the SLA lapses the milestone breaches: its breach arm runs, a Breached event is written to the timeline, and — if the arm gotos — the run transitions. A milestone that has already been met never breaches (an Assigned milestone is met the moment the slot leaves Unassigned; a Finished milestone the moment the slot is Satisfied), so a breach that fixes the problem (e.g. slot.Assign(lead)) also stops it recurring.

The ambient slot. Inside a milestone body the bare identifier slot is the slot the milestone hangs off:

  • slot.Assign(principal) — assign the slot to a principal (it becomes theirs; the wait continues).
  • slot.Candidates(u) — evaluate the slot's own Candidates predicate against a principal u → a bool. It is the slot's eligibility rule, reusable in a query — Person.Single(u => slot.Candidates(u) && u.IsLead) finds the lead among the slot's candidates as one query.
  • slot.Assignee / slot.Status / slot.IsUnassigned — read the slot's current holder / state.

Naming it. A breach arm may give the slot a name of its own — Unassigned(Slot approver) { … } — and then that name is the slot throughout the body. It is the same one slot either way; naming it just reads better when the body is about a person rather than a mechanism, and it matches how on Expire(PoStatus state) names what is ambient there. The parameter is a Slot and there is exactly one of it.

Assigned {
  Within = TimeSpan.FromHours(4);
  Unassigned(Slot approver) {                 // `approver` IS the slot — `slot` is simply its default name
    var lead = Person.Single(u => approver.Candidates(u) && u.IsLead);
    approver.Assign(lead);
  }
}

What if a breach body throws?#

Nobody called it, so there is nobody to throw to. What that body changed is not kept, the run still waits, and the breach is tried again the next time timers run — so a body that always throws leaves the row exactly as it was. The run's other clocks are not held up by it: a reminder that came due with it fires and is kept.

The failure is recorded on the run, saying which body it was and what it threw, where the app reads it — OrderFlow.For(order).Audit, as an entry whose Message begins The `Unfinished { }` body of a milestone threw when its time came — and it reaches the platform's operators as a faulted run. osy test prints it under a test that fails afterwards, since a row that "never went overdue" is usually this:

✗ a_request_goes_overdue — at time.test.osy:14:3
  Assertion failed: Assert.NotNull(…) failed: expected a non-null value, but got null

⚠ While this test ran, a workflow body threw with nobody to throw to — …
  · RequestFlow, on Request 4a2f4268-…, in state Open — The `Unfinished { }` body of a milestone threw when its
    time came. Nothing it changed was kept, the run still waits, and it is tried again the next time timers run:
    nobody to send it on to

The same holds for on Deadline and on Expire. A Remind body that throws is skipped — its reminder is recorded with the failure, and the next one is still due (Remind (milestone reminders)).

Retries / Backoff / Exhausted — try again before giving up#

For a slot a MACHINE fills — a child workflow, an external callback — the useful answer to a missed deadline is often "try again", not "escalate":

Finished {
  Within  = TimeSpan.FromMinutes(5);
  Retries = 3;                          // three further windows
  Backoff = TimeSpan.FromMinutes(1);    // …each one a minute after the last failed
  Exhausted  { slot.Release(); }        // tried and gave up — hand it to the humans
  Unfinished { goto NeedsAttention; }   // …and this is where the run goes
}

Each retry re-opens the SAME window (Within), delayed by Backoff. When the attempts run out, Exhausted { } runs and then the breach arm decides where the run goes.

  • A retry does not run the breach arm. The breach arm may goto, so running it per attempt would move the run away on the first one and there would be no second attempt. The breach arm is the end of the story, not a step in it.
  • Every attempt is audited, so the timeline shows three breaches and an exhaustion rather than one long silence.
  • The count is per owner. A slot that changes hands gives its new holder a fresh set of attempts, for the same reason they get a fresh budget: attempts burned by someone else are not a commitment they made.
  • On a HUMAN slot you almost certainly want Unassigned { … } instead. It lets you say who to escalate to and when to give up; Retries = N can only re-offer to the same pool on the same terms. It is allowed — three re-offers is odd, not wrong — but it is rarely what you meant.

Backoff takes either a plain time span — the same wait every time — or a retry policy, which says how the wait GROWS and bounds it:

Finished {
  Within  = TimeSpan.FromMinutes(5);
  // Three attempts, five minutes apart, then ten — but never more than an hour idle.
  Backoff = Backoff.Exponential(TimeSpan.FromMinutes(5)).MaxAttempts(3).Cap(TimeSpan.FromHours(1));
  Exhausted  { slot.Release(); }
  Unfinished { goto NeedsAttention; }
}

⚠ Retries = N and a policy's .MaxAttempts(M) are two budgets for one thing, and a milestone declaring both is a compile error. They also count differently — Retries is how many windows follow the first, MaxAttempts is how many there are in total — so Retries = 2 and .MaxAttempts(3) say the same thing. Pick whichever reads better where you are; see Backoff (retry policy).

enter { } — the success counterpart of the breach arm#

A milestone has two outcomes and both have a home. The breach arm runs when the SLA is missed; enter { } runs when the milestone is REACHED — the slot got an owner (Assigned), or it was satisfied (Finished).

Assigned {
  Within = TimeSpan.FromHours(4);
  enter  { Notify(slot.Assignee); }        // it has an owner now — tell them
  Unassigned { … }                          // …and this is what happens if it never did
}

This is the only home for "tell the new owner", and that is why it exists: claiming a slot changes no workflow state, so no state enter { } fires. Without it, an approval app has nowhere to put the most ordinary thing it does.

  • It runs on EVERY assignment, not just the first. A slot released and re-claimed enters Assigned again, and the new owner is told — the same reasoning that gives each holder their own Finished budget. A hook that fired once would go quiet exactly when a slot changes hands, which is when someone most needs to hear.
  • The ambient slot is bound, so slot.Assignee is the person who just took it.
  • It may not goto. Reaching a milestone is orthogonal to workflow state: assignment is about WHO, not about WHERE the run is. Side effects only — the compiler refuses a goto here.
  • It is available on Finished too (fires when the slot is satisfied), though that is often already covered by the on <Event> arm that handled the deposit.

Examples#

using Osysharp.Workflow;

enum Decision   { Approve, Reject }
enum OrderState { Review, Done }

[Principal]
entity Person {
  [Required, MaxLength(200)] string Email;
  security { allow read, create when IsAuthenticated; }
}

entity Order {
  [Required, MaxLength(60)] string Reference;
  [Required] Person Requester;
  OrderState Status;                       // no default: the workflow owns this field
  security { allow read, create, update when IsAuthenticated; }
}

workflow ReviewFlow {
  Tracks    = Order.Status;
  Autostart = true;
  Initial   = Review;

  event Approve(Decision decision);

  state Review {
    subscribe Approve(Decision decision) as Legal {
      Candidates = u => u.Email != "";
      Assigned {
        Within = TimeSpan.FromHours(4);
        Unassigned { }
      }
    }
    on Approve(Decision decision) { goto Done; }
  }
  terminal success Done { }
}

Breach ≠ failure — nobody picked up the pool slot, so on breach we widen it to the department lead and keep waiting:

subscribe Approve(Decision decision, string reason) as Legal {
  Candidates = u => u.Department == Dept.Legal;
  Assigned {
    Within = TimeSpan.FromHours(4);
    Unassigned {                                   // no goto → keep waiting, now with an owner
      var lead = Person.Single(u => slot.Candidates(u) && u.IsLead);
      slot.Assign(lead);
    }
  }
}

A claimed slot that goes overdue is fatal — the goto ends the wait:

Finished {
  Within = TimeSpan.FromHours(8);
  Unfinished { goto Escalated; }
}

See also#

  • subscribe — the slot a milestone hangs off (its Candidates / Assignee)
  • Candidates (slot) — the same Candidates(u) question OUTSIDE a milestone, where the run is named rather than ambient: <Wf>.For(item).<Slot>.Candidates(u)
  • Remind (milestone reminders) — nudges scheduled off a milestone before it breaches
  • on — a route and its arms — the state-level on … goto … routes a breach goto joins
  • <span class="planned" title="this page is planned and not written yet">workflow-state</span> — the enclosing state and its Expire deadline

Related

subscribe

Declares that a workflow state waits on an event, and configures the wait — who may hold it, who may hand it on…

on — a route and its arms

An `on` route is what a state does when an event arrives: a flat body, or guarded `when` arms tried top to bottom with…

Remind (milestone reminders)

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

Backoff (retry policy)

A retry policy as a value: how long to wait before the next attempt. Three shapes say how the wait GROWS — `Fixed` (the…