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 + nullable fields (additive):

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
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
TargetPath, SubjectId, Role, Pin what the continuation does (and to whom)
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 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.

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/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.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.