# ScheduledFunction CRD

The ScheduledFunction custom resource fires a function-mode AgentRuntime on a
cron schedule, optionally carrying a **cursor** from one fire into the next.

There is no new deployable and no per-fire Kubernetes object. The operator
dispatches over HTTP with its own ServiceAccount token, and each fire's history
is the ordinary `sessions` row the facade already writes.

## API version

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: ScheduledFunction
```

**Short name:** `sf` (e.g. `kubectl get sf`).

Only **function-mode** AgentRuntimes can be scheduled — the target is invoked at
`POST /functions/{name}`, so a conversational agent has no schedulable surface.

## Minimal example

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: ScheduledFunction
metadata:
  name: hourly-digest
  namespace: my-workspace
spec:
  functionRef:
    name: digest-fn
  schedule:
    cron: "@hourly"
  runAs:
    systemUser: {}
```

## Spec fields

### `functionRef`

The function-mode AgentRuntime to fire, in the same namespace.

| Field | Type | Required |
|-------|------|----------|
| `name` | string | Yes |

### `schedule`

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `cron` | string | — (required) | Five-field cron expression (`0 6 * * *`) or a descriptor: `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`, `@every <duration>` |
| `timezone` | string | `UTC` | IANA zone the expression is evaluated in |
| `concurrencyPolicy` | enum | `Forbid` | `Allow` or `Forbid` |

The controller's parse is the source of truth for what counts as a valid
schedule. The CRD carries no pattern constraint: one that admitted every
descriptor also admitted nonsense.

:::caution[DST is unguarded, matching Kubernetes CronJob]
In a DST-observing timezone, a schedule whose slot falls in the repeated hour
fires twice on fall-back day, and one whose slot falls in the skipped hour does
not fire on spring-forward day. Slots outside the transition hour are unaffected.

There is no skip-the-repeated-slot guard: for an hourly or finer schedule, the
repeated 01:00–02:00 is a genuine extra hour of data, so the guard would only
be correct for daily-or-coarser schedules and could not apply uniformly.

**A scheduled function must be idempotent within a fire slot.** A cursor
absorbs most of the exposure: a watermark-driven function fires twice and
processes an empty window the second time. Non-idempotent side effects are on
you.
:::

`concurrencyPolicy: Replace` does not exist. An in-flight HTTP invocation cannot
be cancelled; abandoning the response still leaves the runtime spending provider
tokens to completion.

:::note[Known limitation of `Allow`]
`status.inFlight` tracks **one** fire. With overlapping fires the marker and
`lastInvocationID` describe whichever fire touched them last, restart recovery
can resolve only one of them, and two concurrent cursor advances are
last-write-wins — which can move the cursor *backwards*. That is safe, because a
re-processed window is recoverable, but it is not free. **Prefer `Forbid` for
cursor-carrying schedules.**
:::

### `state`

Declares which slice of a fire's output is carried into the next fire.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `cursorFrom` | string | Yes | JSON Pointer (RFC 6901) into the fire's **output** |
| `cursorInto` | string | Yes | JSON Pointer into the next fire's **input** |
| `initial` | JSON | No | Cursor value used before the first successful fire |
| `store` | object | Yes | Where the cursor is kept |

If `cursorFrom` resolves to nothing on an otherwise successful fire, the stored
cursor **carries forward unchanged** — a function that returned no new cursor
has not asked to forget where it was.

`initial` accepts a scalar (`"2026-01-01T00:00:00Z"`, `0`, `"page-2"`) as well
as an object. The useful cursor shapes are mostly scalars.

:::danger[The cursor is a resumption pointer, never a system of record]
Legitimate: a watermark timestamp, a last-processed id, a page token.

**Illegitimate: the only copy of a business fact, or an accumulated ledger.**

If the state store is lost, the correct outcome is that the next fire
reprocesses a window — not that data is gone. Design for that.
:::

#### `state.store`

`configMap` is the store provider, and it must be set (CEL: `has(self.configMap)`).

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `configMap.name` | string | `<schedule-name>-state` | ConfigMap holding the cursor |
| `configMap.key` | string | `cursor` | Key within its `data` |

A ConfigMap the controller creates from the **default** name is owned by the
schedule and garbage-collected with it. One you **name explicitly** is adopted,
written to, and left behind on delete, so state can outlive a recreate.

### `runAs`

| Field | Type | Description |
|-------|------|-------------|
| `systemUser` | object | Fire as a stable per-schedule principal, `system:schedule:<workspace>/<name>` |

`systemUser` is the only arm, and it must be set (CEL: `has(self.systemUser)`).

Because the principal is **stable per schedule**, agentic memory continuity
falls out for free: successive fires are the same end user, so a schedule can
remember across runs without any additional field.

### Other fields

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `suspend` | boolean | unset | Stop firing without deleting the object. Unset is a **third state** — see below |
| `startingDeadlineSeconds` | int32 | `300` (min 0) | Skip a fire that is later than this past its slot — after operator downtime, schedules resume rather than storm through every tick they slept past |
| `timeoutSeconds` | int32 | `60` (min 1) | Abandon a fire that runs longer than this; the fire's outcome is `TimedOut` |
| `input` | object | — | The frozen parameter set, validated against the function's `inputSchema` before any fire. The cursor is written into it at `state.cursorInto` |

## Status fields

| Field | Description |
|-------|-------------|
| `nextFireTime` | When the schedule fires next |
| `lastScheduleTime` | The last cron tick the schedule handled, whether it fired or skipped it; `nextFireTime` is computed forward from here |
| `lastFireTime` | When the last fire was dispatched |
| `lastSuccessTime` | When a fire last succeeded |
| `lastOutcome` | `Succeeded`, `Failed`, `TimedOut` or `Unknown` |
| `lastInvocationID` | Correlates with the session row for that fire |
| `lastSkipTime` | When a due tick was last skipped |
| `lastSkipReason` | Why a due tick did not fire |
| `consecutiveFailures` | Reset on success |
| `virtualUserID` | The pseudonymous subject this schedule's sessions are recorded under (`sessions.virtual_user_id`); filter on it to list the schedule's past fires |
| `lastManualTrigger` | The `omnia.altairalabs.ai/trigger-now` annotation value already acted on |
| `state` | Where the cursor lives — `provider`, `ref` and `updatedAt`. **Never the value itself** |
| `inFlight` | Marker for a dispatch in progress — `invocationID` and `since`. A marker older than `timeoutSeconds` resolves to outcome `Unknown` on controller restart |
| `conditions` | `SpecValid` (is the spec and what it references valid) and `Ready` (did the last fire work) |
| `observedGeneration` | Last generation the controller observed |

### Manual fire

Setting the annotation `omnia.altairalabs.ai/trigger-now` to a new value fires
the schedule once. It is a real fire: it splices and advances the cursor,
records the same session row, and obeys `concurrencyPolicy`. It fires once per
distinct value, however many reconciles observe it. A suspended schedule
ignores it, and records it in `lastManualTrigger` as handled, so unsuspending
the schedule does not replay it. To fire after unsuspending, set a new value.

### `suspend` has three states, not two

`suspend` is optional, so `true`, `false` and **unset** are all distinct:

| Value | Meaning | Fires? |
|-------|---------|--------|
| `true` | Somebody paused this | No — `lastSkipReason: Suspended` |
| `false` | Somebody said this should run | Yes, always |
| unset | Nobody has said | Depends on the operator (below) |

By default, an unset `suspend` fires.

An operator started with `--hold-unreviewed-schedules` (chart value
`scheduledFunctions.holdUnreviewed: true`) instead holds every schedule whose
`suspend` is unset, reporting `lastSkipReason: SuspendedByDefault`. An explicit
`suspend: false` is never overridden, so a schedule that states its intent runs
on any operator.

See [Schedule a function](/how-to/agents/schedule-functions/#holding-schedules-nobody-has-reviewed)
for when to enable `--hold-unreviewed-schedules`.

### Skip reasons

| Reason | Meaning |
|--------|---------|
| `Suspended` | `spec.suspend` is true |
| `SuspendedByDefault` | `spec.suspend` is unset and the operator runs with `--hold-unreviewed-schedules` |
| `MissedDeadline` | The tick was later than `startingDeadlineSeconds` |
| `ConcurrencyForbidden` | A fire was still in flight under `Forbid` |
| `DispatchCapReached` | The operator's global in-flight dispatch cap was full |
| `DispatchStartFailed` | The dispatch could not be started |

`DispatchCapReached` is a **global** operator cap, not a per-workspace quota. It
protects the operator from one workspace's fan-out saturating dispatch and
slowing every other reconciler. A fire that cannot acquire a slot is skipped,
never queued.

The cap is the operator flag `--scheduled-function-max-concurrent-dispatches`,
an integer. The default is 10 in-flight fires across every schedule in the
cluster; a value of 0 or less also selects 10. The chart has no value for
it; set it through `operator.extraArgs`.

A manual fire refused with `DispatchCapReached` stays pending: the controller
restores `status.lastManualTrigger` to its previous value, so the next reconcile
retries the same `trigger-now` request. A manual fire refused with
`ConcurrencyForbidden` is consumed.

`status` holds scalars only. The cursor's **value** lives in the state store,
never on the object — `status.state` is a pointer to it, so a cursor carrying a
customer identifier does not end up readable in `kubectl get sf -o yaml`.

## Cursor advance semantics

The cursor advances **only** on HTTP 200 with output that is valid JSON and
satisfies the function's `outputSchema`. If the advance itself fails, the fire
is recorded as `Failed`. There are no retries within a period: the next tick is
the retry.

## Metrics

The operator registers these on its metrics endpoint. They are operational
signals; `status.lastOutcome` is the record of whether a fire succeeded.

| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `omnia_scheduled_function_fires_total` | counter | `schedule`, `namespace`, `outcome` | Settled fires by outcome (`Succeeded`, `Failed`, `TimedOut`). A cursor advance failure counts as `Failed` |
| `omnia_scheduled_function_fire_duration_seconds` | histogram | `schedule`, `namespace` | Seconds from `status.inFlight` being set to the fire settling, for every outcome. Default Prometheus buckets |
| `omnia_scheduled_function_seconds_since_last_success` | gauge | `schedule`, `namespace` | Seconds since `status.lastSuccessTime`, set once per reconcile. Absent until a fire has succeeded |
| `omnia_scheduled_function_cursor_write_failures_total` | counter | `schedule`, `namespace` | Fires downgraded to `Failed` because the cursor could not be saved to the store |

`schedule` is the ScheduledFunction name. An `Unknown` outcome, resolved when the
operator restarts, is not counted in `fires_total`.

## Printed columns

```console
$ kubectl get sf
NAME            READY   FUNCTION    SCHEDULE   SUSPENDED   LAST        NEXT                   AGE
hourly-digest   True    digest-fn   @hourly    false       Succeeded   2026-08-22T15:00:00Z   3d
```

## Related

- [Schedule a function](/how-to/agents/schedule-functions/) — task guide
- [AgentRuntime CRD](/reference/core/agentruntime/#mode) — `mode: function`