Summary#
<Slot>.CallbackUrl() exists so that a person with no account can complete one slot —
a supplier confirming a delivery, an invitee accepting. Every other driver verb in a .test.osy acts as somebody,
so without this one an app could assert that a link was minted and could never assert that answering it works.
Workflow.Redeem(url) answers it. It runs genuinely anonymous — it does not pass the test's [runas] principal,
or any principal — so what a passing test proves is that no principal was involved.
Signature#
Workflow.Redeem(<url>); // an event with no parameters
Workflow.Redeem(<url>, <json>); // the event's arguments, as the JSON a third party would POST<url> is the string CallbackUrl() returned. <json> is text, not a typed argument list — deliberately: the wire
contract is "POST the event's parameters as a JSON object, by name", so the test drives the same bytes a stranger's
curl would. A typed form would prove something weaker by skipping the bind that the contract is made of.
Description#
Refusals are faults, so Assert.Throws reads them#
A refusal is not a return value to inspect — it arrives the way every other refusal in this surface does:
| what happened | the fault |
|---|---|
| the token is unknown, or was already spent | NotFoundException |
| the slot has closed — satisfied, cancelled, or its run finished | ConflictException |
| the body does not fit the event's signature | ValidationException |
a Requires criterion does not hold | RequirementsNotMet |
a gate the callback still meets refused (a Pending slot) | NotAuthorized |
⚑ Unknown and SPENT are the same answer on purpose. Telling an unauthenticated caller which of the two it hit tells it that a token exists. A CLOSED slot is distinguishable because the holder needs to know their item was withdrawn rather than that their link was corrupted.
What it does around the call#
It settles staged writes first — the URL almost always came off a row the test just created, and the run has to exist before a token can address it — and after a successful deposit it drives the background pump and drops what the test's context had already loaded. The deposit happens on the engine's own context, so without that last step the next line reads pre-deposit values and a callback that really worked reads as one that silently did nothing.
Examples#
A supplier who is not a user of the app, and cannot become one — Candidates admits only staff, so the link is the
only way this run can reach Confirmed:
enum OrderState { Awaiting, Confirmed, Refused }
[Principal]
entity Person {
[Required, MaxLength(100)] string Name;
bool IsStaff;
security { allow read, create when IsAuthenticated || IsAnonymous; }
}
entity Order {
[Required, MaxLength(100)] string Reference;
OrderState State; // no default: the workflow autostarts, so `Initial = Awaiting` IS this field's value
[MaxLength(400)] string? ConfirmLink;
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
workflow OrderFlow {
Tracks = Order.State; Autostart = true; Initial = Awaiting;
event Confirm(bool ok);
state Awaiting {
subscribe Confirm(bool ok) as SupplierOk { Candidates = u => u.IsStaff; }
enter { this.Item.ConfirmLink = SupplierOk.CallbackUrl(); }
on SupplierOk(bool ok) {
when (ok) { goto Confirmed; }
default { goto Refused; }
}
}
terminal success Confirmed { }
terminal cancel Refused { }
}[TestFixture]
void Seed() {
new Person { Name = "Sam", IsStaff = true };
}
principal Sam => Person.Single(p => p.Name == "Sam");
// THE FEATURE: a caller with no account completes the slot, while the same deposit made as a signed-in
// NON-candidate is refused. Both halves in one test — a gate that admitted everyone would pass either alone.
[Test(Seed)]
[runas(Sam)]
void a_holder_of_the_link_confirms_with_no_account_at_all() {
var o = new Order { Reference = "PO-1" };
Workflow.Settle(o);
Assert.NotNull(o.ConfirmLink);
Workflow.Redeem(o.ConfirmLink, "{\"ok\": true}");
Assert.Equal(OrderState.Confirmed, o.State);
}
// SINGLE-USE — a forwarded link cannot be answered by a second party, which is a different problem from a
// double click and the one that actually bites.
[Test(Seed)]
[runas(Sam)]
void a_forwarded_link_is_dead_once_it_has_been_used() {
var o = new Order { Reference = "PO-2" };
Workflow.Settle(o);
var link = o.ConfirmLink;
Workflow.Redeem(link, "{\"ok\": true}");
Assert.Equal(OrderState.Confirmed, o.State);
Assert.Throws<NotFoundException>(() => Workflow.Redeem(link, "{\"ok\": true}"));
}
// THE PAYLOAD IS CHECKED, NOT TRUSTED — it is bound against the event's own signature, so a property the event
// never declared is refused rather than ignored.
[Test(Seed)]
[runas(Sam)]
void a_body_the_event_does_not_declare_is_refused() {
var o = new Order { Reference = "PO-3" };
Workflow.Settle(o);
Assert.Throws<ValidationException>(() => Workflow.Redeem(o.ConfirmLink, "{\"approved\": true}"));
Assert.Equal(OrderState.Awaiting, o.State);
}⚠ Note the [runas(Sam)] and the fact that it changes nothing about the redemption. The attribute governs the
rest of the body — creating the order is Sam's act. Answering the link is nobody's.
And the property most worth pinning, because a reader will not guess it — an event's [Authorize] is not evaluated
either, since a predicate takes a principal and a callback has none:
// The event declares [Authorize(u => u.Email == this.Item.Email)] — and a signed-in stranger IS refused by it…
runas (Mallory) { Assert.Throws<NotAuthorized>(() => Onboarding.RaiseAccept(inv)); }
// …while the LINK, held by nobody, is accepted.
Workflow.Redeem(inv.AcceptLink);See also#
- Callback URLs — letting an outsider complete one slot — minting the link, and the five properties that bound it
- signup by invitation (invite, accept link, chase, expire) — the complete flow these examples are drawn from
- runas — acting AS somebody, which this verb is the absence of