Tarski/ docsJoin the waitlist

Scheduling

Standing cron, interval, and one-shot schedules whose exactly-once fires enter the lineage as ordinary observations — never a second source of truth.

The workspace scheduler lets an app declare standing schedules — cron calendars, fixed intervals (every), and one-shots (at) — that deterministically append schedule fire observations into a lineage. The scheduler is an input authority only: it never derives facts, creates intents, or executes effects. “Every weekday at 09:00, refresh the feed” composes exactly like everything else:

schedule fire observation → mapper atoms → rules → intent.* → declared effect capability

Fire observations

Fires arrive as schedule.tick observations (reserved source capability:scheduler) carrying the schedule id and digest, the scheduled_for instant, the fire kind (occurrence, catch_up, or manual), DST and catch-up metadata, the timezone-database version, and your declared payload under data.

Occurrence identity is (lineage_id, schedule_id, scheduled_for), appended through the idempotent observation contract — crash recovery and authority handover resolve to the original receipt, so “exactly once” is structural. Every schedule mutation itself appends a control observation with actor context, making the registry rebuildable from the lineage.

Declaring and managing schedules

Manifest-declared schedules live in tarski.toml under [schedules.<id>] and reconcile at activation; the API can pause, resume, and trigger them, but update and removal fail closed with schedule.manifest_origin_immutable — the manifest stays authoritative. Dynamic schedules are managed by CLI:

tarski schedule list
tarski schedule create
tarski schedule preview
tarski schedule pause
tarski schedule trigger
tarski schedule fires
tarski schedule simulate

Each runtime mutation targets one explicit lineage and appends one reserved control observation:

Operation Observation/state consequence
create or replace system.schedule.set with the complete canonical definition
pause or resume system.schedule.set with the changed enabled state
remove system.schedule.removed
trigger an idempotent manual fire; the registry is unchanged
list, show, fires, preview read-only

The TypeScript client exposes listSchedules, readSchedule, createSchedule, replaceSchedule, pauseSchedule, resumeSchedule, removeSchedule, triggerSchedule, readScheduleFires, and previewSchedule. There is no implicit “current organization” lineage in the runtime contract; pass the lineage id in every generated request. Hosted mutation requires management:schedule.write, reads require management:schedule.read, and agent sessions are denied unless their explicit write policy and session scope grant access.

The cron dialect is five-field with names, ranges, lists, steps, ?, L, W, #, and @hourly-style aliases. Restricting both day fields without a ? fails closed. DST gaps fire once at the transition instant (marked dst_adjusted); folds fire the earlier instant. Occurrence computation is a pure function pinned by a golden conformance corpus.

Per-schedule catch-up policy covers downtime: skip, fire_once (default), or a bounded fire_all.

Firing authority

Exactly one firing authority exists per lineage, bound to the committed effect-authoritative activation: local live (tarski serve / tarski dev), local offline catch-up (tarski run), or the hosted durable scheduler (which wakes hibernated workspaces when fires come due). Replay, verification, shadow builds, and fixtures consume recorded fire observations — they never fire.

Verifying scheduled behavior

tarski schedule simulate renders the exact fire observations across a virtual window as fixture JSONL — byte-identical across runs — so scheduled behavior is tested like any other behavior. tarski verify includes a schedules check: manifest definitions validate, fixture fires must match the payload contract, and simulation determinism is re-proven per run.

When a fire produces no effects, tarski run names why with stable zero-effect reasons: schedule_fire.no_intent_derived, schedule_fire.no_effect_admitted, effect_authority.shadow, http.credential.unresolved, or effect_request.blocked.

One-shot timer request, fire, and replay contract

Use timer.schedule for a deadline derived from evidence, rather than a standing operational calendar:

[capabilities]
timers = true

[capabilities.intents]
"intent.follow_up_timer_requested" = {
  capability = "timer.schedule",
  identity_keys = ["follow_up_id"]
}

The intent’s normalized object has this shape:

{
  "fire_at_ms": 1784800800000,
  "kind": "follow_up.due",
  "reference": "follow-up:f_42",
  "payload": { "follow_up_id": "f_42", "account_id": "a_7" }
}

Only fire_at_ms is required. kind defaults to timer.tick; payload defaults to an empty object. When ready, the effect-authoritative runtime appends one observation with source capability:timer.schedule, the chosen kind and reference, system-owned observed_at, and this payload:

{
  "follow_up_id": "f_42",
  "account_id": "a_7",
  "fire_at_ms": 1784800800000,
  "fired_at_ms": 1784800801250,
  "late_by_ms": 1250
}

The full intent tuple is not copied automatically. Put every field the receiving mapper needs in payload or the stable reference. fired_at_ms is the actual dispatch instant, fire_at_ms the requested instant, and late_by_ms their non-negative difference; mappers can suppress stale work without reading an ambient clock.

Timer admission follows normal identity_keys. An unchanged re-derivation reuses its request. Changing a non-key field changes the canonical execution contract and admits a distinct request under that identity rather than silently mutating the pending timer. Only the committed effect-authoritative package dispatches. Replay and shadow runs consume recorded fire observations and never schedule them again.

Generate a fixture-ready fire deterministically:

tarski timer simulate \
  --request '{"fire_at_ms":2000,"payload":{"follow_up_id":"f_42"}}'

The simulator reads no wall clock and appends nothing.