Read first: Asynchronous Calls (everything here is reactive + IIoPool-bounded, never async/await) and Access Context Propagation (the runner writes as system). This engine supersedes the former ScheduledActionRunner; it is the general form of the same reconcile-on-startup pattern.

What it is

An EventSubscription is the durable record of "when THIS trigger fires, run THAT continuation" — and it survives a reboot. It is a MeshNode at Admin/EventSubscription/{id} (the always-present Admin partition), and a single background EventSubscriptionRunner drives every one of them by two complementary paths:

Because the mesh node is the durable source of truth (Postgres), durability is free — we do not need Orleans reminders or durable streams (they aren't wired, and the monolith host has no Orleans at all; the reconcile query is host-uniform).

The shape

EventSubscription (project MeshWeaver.Mesh.Contract) is flat + enum-discriminated — it serialises through the mesh content serializer like any node content (no polymorphic-content risk), and new trigger/continuation kinds are added as new enum values APPENDED at the end + nullable fields (additive). A module's own effect needs no member of its own: it uses the generic DeliverToNode (see "A module's own continuation: DeliverToNode" below):

Group Field Meaning
Trigger TriggerType NodeChange | Timer | NodeStatus
NodeChange TriggerNodeType, TriggerKind, MatchField, MatchValue fire when a node of this type is created/updated/deleted and its field matches
NodeChange (exact path) TriggerPath + TriggerKind = Deleted fire when ONE exact node path is deleted — no type, no read-back (see "Trigger 1b")
Timer FireAt fire once at/after this instant (a past time fires on the next boot)
NodeStatus WatchPath, StatusField, RestingValues, RequireActiveFirst fire when the watched node's status enters a resting value
Effect ContinuationType GrantSpaceAccess | AddToGroup | PostThreadMessage | PublishSocialPost | RunScript | DeliverToNode
TargetPath, SubjectId, Role, Pin what the continuation does (and to whom)
DeliverTo [DeliverToNode] the node whose own hub acts on the firing
Lifecycle Status, CreatedBy, CreatedAt, FiredAt, LastError runner-managed (Pending → Fired \| Failed \| Cancelled)

Trigger 1 — react to a CRUD event (the email invite)

The flagship case: invite someone to a Space by email. If they already have an account, grant now. If not, write an EventSubscription that grants (and pins) the moment a User with that email is created — so access lands automatically on sign-up, surviving any restart in between. This is exactly what SpaceInviteService writes:

var subscription = new EventSubscription
{
    // Deterministic id per invitee+space → a re-invite upserts the SAME subscription (idempotent).
    Id = $"grant_{Slug(email)}_{Slug(spacePath)}",
    TriggerType = EventTriggerType.NodeChange,
    TriggerNodeType = "User",
    TriggerKind = MeshChangeKind.Created,
    MatchField = "email",
    MatchValue = email,
    ContinuationType = EventContinuationType.GrantSpaceAccess,
    TargetPath = spacePath,
    Role = "Editor",
    Pin = true,
};
EventSubscriptionOps.CreateSubscription(meshService, subscription).Subscribe();

When the invitee onboards, their User node is created → the change feed fires the subscription live; the reconcile path is the safety net if the sign-up happened during downtime. The continuation (GrantSpaceAccess) creates the {space}/_Access/{user}_Access assignment and pins the Space — both idempotent create-or-updates, so live + reconcile can never double-grant. The subject is the triggering node's id (a User node's path IS the userId).

The group twin is AddToGroup (written by GroupInviteExtensions.InviteToGroup / the bulk InviteAllToGroup behind the group's "Invite by Email" dialog): on sign-up it creates the {group}/{user}_Membership node, and — when the invite chose a Role — additionally the {group}/_Access/{user}_Access assignment (groups are not publicly readable, so the grant is what lets the new member see the group; Admin makes them a group manager). Same idempotent upserts, same deterministic id per invitee+group.

Trigger 1b — react to ONE exact path being deleted

A type-matched Deleted trigger cannot express "when THIS record is deleted": a delete event carries no authoritative node type, and the deleted node can no longer be read back to match a field. TriggerPath is the exact-path form. With TriggerKind = Deleted the subscription matches by path alone — TriggerNodeType, MatchField and MatchValue are not consulted — and the continuation's subject is the deleted node's id:

new EventSubscription
{
    Id = $"revoke_{recordId}",                         // deterministic → re-arming upserts the same one
    TriggerType = EventTriggerType.NodeChange,
    TriggerKind = MeshChangeKind.Deleted,
    TriggerPath = $"Admin/Subscriptions/{recordId}",   // exactly this node, never a sibling or a prefix
    ContinuationType = EventContinuationType.DeliverToNode,
    DeliverTo = ownerPath,                             // the owner re-decides with the record gone
    CreatedBy = owner,
};

When it fires — every path is gated on an authoritative "it is gone" read. Three paths can propose a firing, and none of them fires on its own word: each hands the subscription to one authoritative point read of the path (GetMeshNodeOutcome, as System), and only Absent — or DeleteInProgress, whose next answer is "gone", never "here again" — fires. Present never fires (the path was re-created, or the hint was stale). An Absent that carries a read validator's verdict (NodeReadOutcome.HiddenBy) is a node that EXISTS but was hidden from the reader — the owner runs read validators only over a node it holds, and custom validators run for System too — so it never fires either. Neither it nor Unavailable is evidence of a delete: nothing fires and the check is released for a later emission or the next start.

One confirm-and-fire per subscription. Every proposal reserves the subscription's slot before its read, so two proposals (the feed and the watch, say) can never run two reads. The reservation is held once it fired, until the subscription leaves the pending set, and before it fires the runner re-reads the SUBSCRIPTION and requires it still Pending — the rule the timer applies at fire time. The read, the catch-up listings and the watches are owned by the runner: a stop disposes them, and a reply that lands after the stop starts nothing.

The guarantee is the runner's ordinary one: at most one continuation in flight per subscription (the executing reservation), the terminal Fired write gates re-entry across restarts, and a crash after the continuation landed but before Fired was written re-delivers on the next start — so a DeliverToNode receiver must be idempotent (re-deciding from current state is). A TriggerPath the runner cannot honour ends Failed, named, rather than being read as a type trigger with no type: one with any kind other than Deleted, one that is empty once trimmed of whitespace and /, and one the query language cannot write as a single literal.

Trigger 2 — react to a timer

Fire a continuation at (or after) a time. The runner schedules one Observable.Timer per pending Timer subscription; a FireAt already in the past fires immediately on the next startup — restart-safe at-least-once without any external scheduler. A timer carries no triggering node, so the continuation subject is on the subscription (SubjectId):

var subscription = new EventSubscription
{
    TriggerType = EventTriggerType.Timer,
    FireAt = DateTimeOffset.UtcNow.AddDays(7),        // e.g. grant a trial role in a week
    ContinuationType = EventContinuationType.GrantSpaceAccess,
    SubjectId = userId,                               // no trigger node → subject is explicit
    TargetPath = spacePath,
    Role = "Viewer",
};

Trigger 3 — react to a node reaching a resting status

Fire when a watched node's status field leaves "running" and reaches a resting value — the exact shape of "wait for a reply, then continue". The runner watches the node via the self-healing SubscribeWithReEstablish (re-establishes on a transient fault, and terminally stops without a storm when the watched node is gone), tracks whether it first saw a non-resting (active) state (RequireActiveFirst, so an initial replayed-resting of a node that never ran doesn't fire), and fires when the status enters RestingValues:

var subscription = new EventSubscription
{
    TriggerType = EventTriggerType.NodeStatus,
    WatchPath = subThreadPath,                        // the delegated sub-thread
    StatusField = "Status",
    RestingValues = ["Idle", "Cancelled", "Done"],    // "not running any more"
    RequireActiveFirst = true,                        // saw it Executing first → this is a genuine finish
    ContinuationType = EventContinuationType.PostThreadMessage,
    TargetPath = parentThreadPath,                    // continue the parent
};

This is the durable backbone of delegation: a parent agent delegates to a sub-thread and continues when the sub-thread finishes. The in-memory wait is the fast path for the same-process happy case — DelegationTool.WaitForDelegationResult, an IObservable<string> folded off the sub-thread's node stream (Scan for "saw it running, now resting" → Take(1).Timeout(...)), bridged to the tool's Task<string> return only at the model-facing tool boundary. The EventSubscription is the reboot backstop — if the portal restarts mid-delegation, the runner reconciles the subscription, sees the sub-thread already resting, and continues the parent. Nothing is lost. (See Thread Operations.)

Asking a question vs. done. A sub-thread that finished a task and one that asked the user a clarifying question both currently reach Idle — the thread status alone can't tell them apart. The RequireActiveFirst + summary-presence heuristic covers the common case. An explicit round disposition (something like Completed / AwaitingInput / Failed) would make the resting trigger fire only on genuine completion; it is a proposal, not a type that exists today.

A module's own continuation: DeliverToNode

In-mesh source cannot register a root-DI IEventContinuationHandler, so before DeliverToNode it could not own a continuation at all. DeliverToNode is the ONE generic continuation for that: the runner writes an EventDelivery (subscription id, the subject's id — for a sign-up, the new user's id — the time, and CreatedBy) onto the content of the node named by DeliverTo, as System, through the node's own stream. The node's own hub, which belongs to the module's node type, decides what the firing means. The node must exist when the subscription fires.

new EventSubscription
{
    TriggerType = EventTriggerType.NodeChange,
    TriggerNodeType = "User",
    MatchField = "email",
    MatchValue = email,
    ContinuationType = EventContinuationType.DeliverToNode,
    DeliverTo = "Admin/TierInvites/someone_example_com",   // its content declares EventDelivery? EventDelivery
    CreatedBy = inviter,                                    // the receiver re-checks this person's right
};

The receiver re-checks EventDelivery.CreatedBy before it acts: the firing comes long after the request was written, and the person who asked may have lost the right in between (policy authorize-as-caller-execute-as-system). The Store's tier invitation (MeshWeaver.Plugins, Store/TierInvite) is the first receiver. A delivered subscription is Fired once the delivery is written; what the module then does is reported on its own node.

How the runner resolves a continuation. The platform's members run in the runner; DeliverToNode delivers (and fails, named, without a DeliverTo); everything else goes to the exactly-one DI-registered IEventContinuationHandler claiming it (PublishSocialPost reaches MeshWeaver.Social that way). A value nobody claims — including a number this build has never heard of — is logged by name and ends Failed with the value on LastError; it is never run as a continuation that means something.

Why an enum member, not open string constants (yet). EventContinuationType is persisted and module-extended, so policy open-vocabulary-string-constants says it should become a static class of const string. That conversion is a BINARY break for modules already compiled against the enum (MeshWeaver.Social's handler declares the enum type; MeshWeaver.Plugins' ContentIndexBackfill calls the enum-typed setter), so it is a planned, coordinated rebuild, not a side effect of a feature. Appending a member is binary-safe: every earlier member keeps its number.

Why one runner, reconciled on startup (not Orleans reminders)

Back-compat

EventSubscription generalises the former ScheduledAction (same field names for the NodeChange + GrantSpaceAccess case). On startup the runner migrates any legacy Admin/ScheduledAction/{id} nodes into Admin/EventSubscription/{id} (a lag-robust live query; a failed migration releases the id so a later emission retries) — so no in-flight invite is dropped. ScheduledAction is kept only for that deserialization + migration; nothing creates it any more.

Testing

test/MeshWeaver.Graph.Test/EventContinuationDeliverToNodeTest covers DeliverToNode writing the new user's id onto the module's node, DeliverToNode without a node failing by name, an unknown value failing under its own number, and every earlier member keeping its number. test/MeshWeaver.Graph.Test/EventSubscriptionRunnerTest covers the NodeChange grant (fires when the matching user is created), the legacy migration, the Timer (a past-due timer fires + grants), and the NodeStatus trigger (a watched node flipped Running → Idle fires). SpaceInviteServiceTest covers the invite → subscription write. test/MeshWeaver.Graph.Test/EventSubscriptionExactPathDeletionTest covers the exact-path Deleted trigger: deleting the path delivers (subject = the deleted id); deleting a sibling, a string prefix or a descendant does not; a delete while no runner ran delivers when one starts; a path that still exists never delivers; and a path with another kind fails, named.