Flow — triggers and schedules: five sources, two deliveries, three clocks
A stored binding becomes a run when an event matches it or its clock comes due, through one matching prelude, one of two deliveries, and the same launch admission every other surface passes.
Drawn from the code at commit
f564846d3; how this page is kept true: README.
Two objects carry a binding and they are not interchangeable. A trigger.Subscription (trigger_subscriptions, or an in-memory store seeded from bot manifests locally) binds a Matcher to a bot launch and is fired by events — plus, for its schedule and keepalive kinds, by the resident trigger.Scheduler. A cloudsched.ScheduledBot (scheduled_bots) is the cloud cron and keepalive row, fired by cloudsched.Ticker alone. Three facts readers get wrong: cloud board events never touch the event bus, a schedule-kind subscription has nothing to fire it in cloud (the scheduler is built only beside a native board store, and scopes itself to tenant ""), and a skipped tick is a normal audited outcome rather than a failure.
From an event to an effect
Three clocks, one gate
Steps
| Actor | Action | Code |
|---|---|---|
| user | writes a subscription: the active team is the tenant, bot_id is required, the policy is validated and a guard a cloud pod would run is refused | handleCreateTrigger, triggerTenant, schedgate.Validate, cloudGuardRefusal |
| studio | or enables a manifest invocation in one click, through the same From* constructors every provisioning path uses | handleTriggerFromInvocation, FromScheduleInvocation, FromBoardInvocation, FromKeepaliveInvocation |
| user | writes the cloud cron row instead: cron XOR interval_seconds, the keepalive floor, supersede refused here, then the first next_fire_at | handleCreateSchedule, ValidateReapable, NextFireForBot |
| store | tails the native board, filters, and normalizes against the current card | native.Store.Subscribe, BoardSource.enqueue, NormalizeBoardEvent |
| server | publishes on the bus, and verifies a run-lifecycle event against the run document before anything matches | InProcBus.Publish, NATSBus.Publish, verifyRunEventAuthority, runStatusRelayable |
| server | in cloud, poll-tails board_events per tenant and guards the contiguous prefix | cloudBoardSource.tickOnce, drainTenant, trimAtYoungGap, poisoned |
| server | matches once for both deliveries, declining machine-caused and already-launched events | matchingSubscriptions, machineCaused, tracker.IsMachineReason, ListCandidates, Matcher.Match |
| server | materializes durable rows, then advances the cursor — never the other order | MaterializeEffects, projectionOwed, UpsertPending, AdvanceTriggerCursor |
| server | claims due rows under a lease and a claim id, and re-resolves the subscription at execution time | EffectWorker.Tick, ClaimDue, applyClaimedEffect |
| server | promotes the card, or strips the one-shot labels and launches | NativeBoardEffect.Promote, ConsumeMatchLabels, boardmongo.ConsumeLabels, MarkConsumed |
| server | builds the plan and launches as trigger-spine, past the shared admission | Evaluator.buildPlan, serviceLauncher.Launch, gateLaunch, runview.Service.Launch |
| store | raises the verdict on the binding itself, only when the message changes, on a detached context | recordLaunchVerdict, MarkLaunchError |
| server | ticks the three clocks and crosses the one gate | Scheduler.tick, Scheduler.fire, Ticker.Tick, RunScheduleRun, schedgate.Apply |
| server | decides overlap, classifies staleness, runs the guard | EvaluateOverlap, LiveAndStaleRunsForSchedule, RunGuard |
| server | reaps what the gate declared stale — on clock A's gate alone, the two others drop the list | Server.scheduleGate, UpdateRunStatusIf |
| server | audits every tick decision: a JSONL line locally, a schedule.tick.<decision> row in cloud | schedgate.TickRecord, jsonl.AppendJSON, cloudScheduleAudit, ToAuditMeta |
| server | back-writes the launched run's terminal outcome onto the schedule under a monotonicity guard | handleScheduleOutcomeEvent, MarkRunOutcome |
The effect row
Only the cloud board lane has an outbox: the local path publishes onto the lossy bus, where a dropped notification is reconciled by the dispatcher poll on the promote arm and by nothing at all on the direct arm; MemoryEffectOutbox is the test twin, wired nowhere in production. Each row is one effect an event owes — to a matched subscription, or to the tenant's board binding.
| State | Means | Written by |
|---|---|---|
pending | runnable, or waiting out its backoff in not_before | UpsertPending, MarkRetry |
claimed | one worker holds it — reclaimable once not_before, the lease horizon, passes | ClaimDue, two phases so a reclaim spends an attempt |
done | terminal success, a spent one-shot included | MarkDone |
failed | terminal exhaustion at MaxEffectAttempts, kept for an operator | MarkFailed, from executeOne or from the reclaim check |
Every Mark* filters on the row's claim_id, so a worker whose lease was stolen finds its late writes are no-ops. A second field carries its own fact: consume_marked flips false to true between the atomic label consume and the launch, which is what lets a retry re-launch without re-spending the one-shot.
Each tick of each clock lands exactly one schedgate.TickDecision: fired (overlap and guard passed, a launch was attempted), skipped_overlap (a live run held the slot — audited, and deliberately not raised on last_error: busy is not unhealthy), guard_blocked (the guard exited non-zero, and it clears a stale error), guard_error (the guard could not run, and it raises one), launch_failed (the launch itself errored and the slot is spent — LaunchDecision is the single place that mapping lives).
Guards that refuse
- The member rung on every trigger write —
403 member_role_requiredthroughwriteRungDenial, frommutatingRungAuth: inert on reads and on a deployment with no identity store, owed here because a stored subscription is a standing launch order and the spine executing it launches under a machine principal the gate exempts. canViewTeam/canManageTeamon the schedule routes —403 not a memberfor the list,403 admin or owner requiredfor create, patch and delete; a row of another team answers404throughscheduleForTeam, so ids cannot be probed across teams. A trigger of another tenant answers404 ErrSubscriptionNotFoundfor the same reason, never403.- The policy validators —
400for an incoherent policy.ValidateReapable(canReap=false)on the host-cron and cloud-schedule surfaces additionally refusesoverlap: supersede, which cannot mean anything where the caller dropsReapRunIDs; the trigger route calls plainValidate, so a subscription may store and run it. cloudGuardRefusal—422unless the deployment setsITERION_CLOUD_SCHEDULE_GUARDS=allow, and only when the request CHANGES the stored guard, so an existing row stays editable and pausable.- The cadence refusals —
400for both or neither of cron andinterval_seconds, for an interval underbundle.KeepaliveMinInterval, for an invalid cron, and for a bad retry or routing block. - The one-click enablement —
404for a bot outside the team's own tier,400for an index out of range, a cron override on a non-schedule kind, a schedule with nosuggested_cron, a board invocation with noboard:block, a keepalive with no valid interval, and a forge or command kind;409, carrying the existingsubscription_id, for a second bot-home trigger of that kind. - The custom ingress —
503when no spine is enabled,400 kind is required,400 vars payload too largepast 1 MiB, and the launch gate's own denial envelope as a pre-check whose metered slot is rolled back at once, because one emit fans out to however many launches each meter themselves. - The relay's entry check — no status, a bus: a run event whose claimed tenant or kind the document contradicts, or whose status is neither an exit nor a human-gate pause, is dropped with a warn and never matched.
paused_waiting_humanandpaused_operatorrelay, the rebuild re-kinding themrun.paused, so a subscription on that kind does fire. - The matching prelude — an enumerated machine reason and a
launched_run_idpayload both yield no effect and spend no one-shot. - The execution-time re-resolution — a deleted, disabled, cross-tenant or no-longer-matching subscription drops the row terminally; only a transient store error retries.
- The overlap gate and the guard — a live run, a
max_concurrentceiling, a non-zero guard exit or a guard that could not run each pass the slot, audited; the host-crontab surface returns exit 0 on all of them, because a non-zero would mail the operator on every blocked tick. - No application gate at all on the host crontab — whoever can write the schedule manifest and the user's crontab controls that surface.
Collections touched
trigger_subscriptions (the binding; last_error and last_error_at written targeted, only on a change) · scheduled_bots (the cloud cron and keepalive row; next_fire_at and last_fire_at by CAS, last_error* and last_run_* targeted) · trigger_effects (the cloud outbox, claimed under tenant_state_due, done rows expiring after 7 days) · board_events (read by the poll-tail over tenant_seq, and WRITTEN by the one-shot consume — an issue_updated row whose labels are already gone, holding a seq the tail then reads) · board_config (the per-tenant document trigger-cursor:<tenant>, seeded at the highest INSERTED seq so a fresh deployment never replays history) · board_issues (promote stamps issue.bot and issue.botargs, the consume pulls labels) · forge_board_bindings (the projection lane reads it twice: HasBoardBinding at materialization decides whether a row is owed, ReflectCard reads it again at execution; the _id is the team) · runs (launched with source.schedule_id, queried back through the partial index tenant_source_schedule_created, and CAS-flipped to failed_resumable by the local keepalive reap) · audit_events (schedule.created, schedule.updated, schedule.deleted and schedule.tick.<decision>, on the team trail). Locally the tick records go to <manifest-dir>/logs/tick-audit.jsonl instead — the file iterion schedule audit reads — field-compatible with the cloud rows.
Related
- scheduling.md — the host manifest, the overlap policy, keepalive, the guard, retry precedence and the cloud failure surfaces; bot-invocations.md — the manifest capability a subscription is derived from.
- agents/engine/automation-surfaces.md — the four automation families and the spine they converge on; agents/engine/events-leases-and-schedules.md — the package invariants.
- adr/046-event-driven-runs-trigger-spine.md (capability versus binding), adr/094-trigger-effect-outbox.md (the four loss windows and the materialization order), adr/051-in-bot-event-driven-primitives.md (the in-run
emitandwaitregistry, deliberately NOT this bus). - data-model-automation.md — every collection drawn here, with its tenant field, indexes and twin; data-model-runs.md (
runs), data-model-identity.md (audit_events), access-control.md — the rung and the synthetic principals; quotas-and-limits.md — what each surface meters. - flow-webhook-to-run.md — the forge lane whose launch this spine only mirrors; dispatcher.md — the claim a promote hands the card to.
