Summary#
Some senders will never hold anything you gave them. A carrier POSTs parcel scans for its whole network. A payment provider POSTs settlement notices for every merchant it serves. Neither was handed a link for your particular run, and neither can be — they had already built their integration before you existed, and it is keyed on their id for the thing: a tracking number, a reference.
[CorrelateOn] declares which key finds which run, so a message carrying nothing but that key lands in the right
place.
[CorrelateOn(this.Item.TrackingNumber == trackingNumber)]
event Scanned(string trackingNumber, string location);This is the one case a callback URL cannot cover, and the reason is worth stating exactly: a token IS an address. One digest, one slot, one run — so it can never miss and it can never be ambiguous, which is also why it can do nothing for a sender who was never given one. A business key is the opposite kind of thing: it must be searched for, and the search can answer none, one, or many. Everything below follows from that.
Signature#
[CorrelateOn(this.Item.<Property> == <parameter>)] // on the event — declares the key
<Wf>.Correlate<Event>(args…) // in app code — deposits by that key⚠ Correlate<Event> is ONE NAME, not a generic call. The event's name joins the verb, so an event Scanned on
a workflow ParcelDispatch is called ParcelDispatch.CorrelateScanned(…) — angle brackets are this page's
placeholder notation, and writing Correlate<Scanned> is a compile error (unknown identifier). Osy# is a C#
superset, so the brackets read like a type argument; they are not one here.
ParcelDispatch.CorrelateScanned(trackingNumber, "Depot Malmo"); // the real spellingThe attribute takes exactly one equality, between a property of the workflow's tracked entity and one of the
event's own parameters. Either order reads the same. The verb takes the event's arguments and returns a
CorrelationOutcome.
Description#
The key names both sides, and the compiler checks both#
this.Item.TrackingNumber is where the key lives on the tracked row; trackingNumber is which part of the message
carries it. Naming only the message and matching the property by name would make a rename compile clean and stop
correlating silently — which is the failure this attribute exists to avoid, not to reproduce.
It is read as a key, not evaluated as a condition: the platform turns it into a single indexed lookup. That is why it must be one equality. A compound condition would have no key to be unique about and no lookup to be one query.
The key must be [Unique], and that is the whole answer to "what if two runs match?"#
A business key that matches two runs has no honest answer at delivery time — depositing into both is wrong, picking one is arbitrary, and refusing at 3am is a page. It is a modelling error, so it is caught where modelling errors belong:
entity Shipment {
[Required, Unique, MaxLength(60)] string TrackingNumber; // ← without Unique, the app does not compile
ShipmentStatus Status;
}Drop the [Unique] and the compile fails, naming the property.
Your app owns the door; the platform owns the search#
There is no platform endpoint for this, deliberately. A correlated deposit swaps an unguessable 256-bit token for a guessable business key — a tracking number is printed on the parcel — so the door cannot be anonymous the way a callback URL safely can. But every source authenticates differently: an API key, an HMAC signature, mTLS, an IP allowlist. A platform door would have to pick one (wrong for most callers) or accept anything (wrong for all).
So you declare your own route, check the caller however that caller requires, and then call the verb. Three things follow, and they are all improvements rather than costs:
- the authorization decision is written where you can read it, in your own source;
- there is no second forever-URL — a generic inbound path is a contract with third parties who cannot be told it
changed, which is exactly the problem
map eventhad to solve for callback links; - it matches every other seam:
Http.*egress,clientblocks,[Page]routes andapp.Apisare all yours.
⚠ Whatever credential you choose, never render it. A screen that displays the carrier's key hands whoever can see the screen the ability to forge every message that key signs. Rendering a credential is not a display decision; it is a grant.
The outcome is a value, because a miss is ordinary#
A sender POSTs its whole network's traffic at you, and most of it is nobody's business of yours. Raising for the
normal case would make every endpoint a try/catch — and, worse, would flatten the one distinction the caller
actually acts on:
| Outcome | Means | What to tell the sender |
|---|---|---|
Deposited | matched one live run; the event went in | accepted |
NoSuchKey | nothing here carries that key | try again — this is also what a race looks like (the notice beating the row that would match it), and what somebody else's key looks like; you cannot tell them apart, so do not pretend to |
NotRunning | the key names a row, but its run is over | accepted, stop — retrying can never change it |
Ambiguous | more than one live run on that row | a modelling error that got past the gate |
NoRule | the event declares no [CorrelateOn] | a programming error |
A sender retries; that is how its queue works, not a failure mode to defend against. Answering every miss with one "not found" means either their queue hammers you for ever, or they drop a message you wanted.
What correlation does NOT change#
Finding the run is the only thing that differs. After that it is an ordinary deposit: the event's
[[workflow-authorize|[Authorize]]] predicate, the state's slot-or-route dispatch, the per-run lock, and the version
pin are all exactly as they are for <Wf>.RaiseX. A run still on an older revision binds the
event against the contract it started under.
⚠ Your own rows are stale afterwards. The deposit happens on the engine's context, so anything you were holding — and anything you re-query, because the identity map answers with the same instance — shows pre-deposit values until the call returns. The verb re-reads for you; the thing to know is that the values you had before it are not the ones you have after.
Examples#
A parcel scan finds its shipment#
The carrier knows a tracking number and nothing else. Note that nothing in RecordScan names a run, a slot or a row.
enum ShipmentStatus { Booked, InTransit, Delivered }
entity Shipment {
[Required, Unique, MaxLength(60)] string TrackingNumber;
ShipmentStatus Status;
[MaxLength(200)] string? LastSeenAt;
security { allow read, create, update when IsAuthenticated; }
}
workflow ParcelDispatch {
Tracks = Shipment.Status;
Autostart = true;
Initial = Booked;
[CorrelateOn(this.Item.TrackingNumber == trackingNumber)]
event Scanned(string trackingNumber, string location);
state Booked {
subscribe Scanned() as Collection;
on Collection(string trackingNumber, string location) {
this.Item.LastSeenAt = location;
goto InTransit;
}
}
state InTransit {
subscribe Scanned() as Delivery;
on Delivery(string trackingNumber, string location) {
this.Item.LastSeenAt = location;
goto Delivered;
}
}
terminal success Delivered { }
}
// Your own endpoint. Check the caller FIRST — here the route's API key does it — then call the verb.
bool RecordScan(string trackingNumber, string location) {
var outcome = ParcelDispatch.CorrelateScanned(trackingNumber, location);
return outcome == CorrelationOutcome.Deposited;
}Telling the two misses apart#
This is the shape worth copying: one branch per outcome, and a Retry flag the sender can act on.
var outcome = ParcelDispatch.CorrelateScanned(scan.TrackingNumber, scan.Location);
if (outcome == CorrelationOutcome.Deposited) {
return new ScanReceipt { Accepted = true, Retry = false, Message = "recorded" };
}
if (outcome == CorrelationOutcome.NoSuchKey) {
return new ScanReceipt { Accepted = false, Retry = true, Message = "unknown — try again later" };
}
return new ScanReceipt { Accepted = false, Retry = false, Message = "no longer in transit" };A fuller version — the route, the receipt, and every outcome exercised by tests — is
demo/wf-supplier-dispatch.
See also#
- Callback URLs — letting an outsider complete one slot — the other way in, for a sender you CAN hand a link to; a token is an address, a key is a search
- Raising a workflow event — depositing when you already hold the row, which is the ordinary case
- subscribe — the slot the event satisfies once correlation has found the run
- [Authorize] (event) — who may raise an event, which correlation does not bypass