Skip to content
Like what we’re building? Star on GitHub

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 ​

ActorActionCode
userwrites 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 refusedhandleCreateTrigger, triggerTenant, schedgate.Validate, cloudGuardRefusal
studioor enables a manifest invocation in one click, through the same From* constructors every provisioning path useshandleTriggerFromInvocation, FromScheduleInvocation, FromBoardInvocation, FromKeepaliveInvocation
userwrites the cloud cron row instead: cron XOR interval_seconds, the keepalive floor, supersede refused here, then the first next_fire_athandleCreateSchedule, ValidateReapable, NextFireForBot
storetails the native board, filters, and normalizes against the current cardnative.Store.Subscribe, BoardSource.enqueue, NormalizeBoardEvent
serverpublishes on the bus, and verifies a run-lifecycle event against the run document before anything matchesInProcBus.Publish, NATSBus.Publish, verifyRunEventAuthority, runStatusRelayable
serverin cloud, poll-tails board_events per tenant and guards the contiguous prefixcloudBoardSource.tickOnce, drainTenant, trimAtYoungGap, poisoned
servermatches once for both deliveries, declining machine-caused and already-launched eventsmatchingSubscriptions, machineCaused, tracker.IsMachineReason, ListCandidates, Matcher.Match
servermaterializes durable rows, then advances the cursor — never the other orderMaterializeEffects, projectionOwed, UpsertPending, AdvanceTriggerCursor
serverclaims due rows under a lease and a claim id, and re-resolves the subscription at execution timeEffectWorker.Tick, ClaimDue, applyClaimedEffect
serverpromotes the card, or strips the one-shot labels and launchesNativeBoardEffect.Promote, ConsumeMatchLabels, boardmongo.ConsumeLabels, MarkConsumed
serverbuilds the plan and launches as trigger-spine, past the shared admissionEvaluator.buildPlan, serviceLauncher.Launch, gateLaunch, runview.Service.Launch
storeraises the verdict on the binding itself, only when the message changes, on a detached contextrecordLaunchVerdict, MarkLaunchError
serverticks the three clocks and crosses the one gateScheduler.tick, Scheduler.fire, Ticker.Tick, RunScheduleRun, schedgate.Apply
serverdecides overlap, classifies staleness, runs the guardEvaluateOverlap, LiveAndStaleRunsForSchedule, RunGuard
serverreaps what the gate declared stale — on clock A's gate alone, the two others drop the listServer.scheduleGate, UpdateRunStatusIf
serveraudits every tick decision: a JSONL line locally, a schedule.tick.<decision> row in cloudschedgate.TickRecord, jsonl.AppendJSON, cloudScheduleAudit, ToAuditMeta
serverback-writes the launched run's terminal outcome onto the schedule under a monotonicity guardhandleScheduleOutcomeEvent, 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.

StateMeansWritten by
pendingrunnable, or waiting out its backoff in not_beforeUpsertPending, MarkRetry
claimedone worker holds it — reclaimable once not_before, the lease horizon, passesClaimDue, two phases so a reclaim spends an attempt
doneterminal success, a spent one-shot includedMarkDone
failedterminal exhaustion at MaxEffectAttempts, kept for an operatorMarkFailed, 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_required through writeRungDenial, from mutatingRungAuth: 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 / canManageTeam on the schedule routes — 403 not a member for the list, 403 admin or owner required for create, patch and delete; a row of another team answers 404 through scheduleForTeam, so ids cannot be probed across teams. A trigger of another tenant answers 404 ErrSubscriptionNotFound for the same reason, never 403.
  • The policy validators — 400 for an incoherent policy. ValidateReapable(canReap=false) on the host-cron and cloud-schedule surfaces additionally refuses overlap: supersede, which cannot mean anything where the caller drops ReapRunIDs; the trigger route calls plain Validate, so a subscription may store and run it.
  • cloudGuardRefusal — 422 unless the deployment sets ITERION_CLOUD_SCHEDULE_GUARDS=allow, and only when the request CHANGES the stored guard, so an existing row stays editable and pausable.
  • The cadence refusals — 400 for both or neither of cron and interval_seconds, for an interval under bundle.KeepaliveMinInterval, for an invalid cron, and for a bad retry or routing block.
  • The one-click enablement — 404 for a bot outside the team's own tier, 400 for an index out of range, a cron override on a non-schedule kind, a schedule with no suggested_cron, a board invocation with no board: block, a keepalive with no valid interval, and a forge or command kind; 409, carrying the existing subscription_id, for a second bot-home trigger of that kind.
  • The custom ingress — 503 when no spine is enabled, 400 kind is required, 400 vars payload too large past 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_human and paused_operator relay, the rebuild re-kinding them run.paused, so a subscription on that kind does fire.
  • The matching prelude — an enumerated machine reason and a launched_run_id payload 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_concurrent ceiling, 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.