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.