---
title: "Time Based Rules - Workshop Docs"
description: "Time Based Rules - Enterprise control plane for Santa. Manage rules, approvals, telemetry, and policies across your macOS fleet."
doc_version: "1"
last_updated: "2026-09-17"
canonical: "https://northpole.security/docs/workshop/rules/time-based-rules"
---
# Time Based Rules

A time based rule is an [execution rule](https://northpole.security/docs/workshop/rules/execution-rules) whose policy is **CEL** and whose expression calls `policy_for_range()`. The expression returns one policy while a time window is open and a different one while it is closed, so a single rule can allow an application launched during working hours and block the application launches for the rest of the week. Optionally wrapping the in-range policy in `kill_on_expiry()` also quits the processes the rule allowed, once the window closes.

:::info Requirements

`policy_for_range()`, `kill_on_expiry()`, `now()`, `weekdays()` and `today(tz)` require Workshop and Santa **2026.8**. Workshop sets every rule that uses them with a minimum Santa version of 2026.8, so hosts on anything older will not receive the rule.

:::

## Overview

```
policy_for_range(weekdays(), "09:00", "17:00", ALLOWLIST, BLOCKLIST)
```

In the above example, the CEL expression allows the binary to be launched from 09

to 17

, Monday through Friday, each host’s own clock. At any other moment the rule blocks the execution of that binary.

Three properties are worth knowing before you write one:

-   **The call is the whole expression.** `policy_for_range()` returns a decision, so a bare call is a valid rule. There is no separate schedule object to manage: the window lives in the rule, next to the identifier it applies to.
-   **The host decides.** The window is evaluated on the Mac at the moment of the execution, so a host that is offline or asleep still opens and closes its windows on time.
-   **The result is never cached.** Any expression that calls `policy_for_range()` is re-evaluated on every execution. See Cacheability.

## Window Forms

`policy_for_range()` has four forms, one per window shape. The policy arguments are always last.

Form

Window

Typical use

`policy_for_range(list<days>, start, end, policy, out_of_range_policy)`

Weekly `HH:MM` window on each host’s own clock

Working hours, on-call hours, hours a lab machine may be used

`policy_for_range(list<days>, start, end, tz, policy, out_of_range_policy)`

The same window read in the time zone you name

One window fleet-wide, such as a maintenance hour in `America/New_York`

`policy_for_range(timestamp_start, timestamp_end, policy, out_of_range_policy)`

One fixed span between two timestamps

A dated exception: a migration week, an audit, a vendor’s support window

`policy_for_range(duration, kill_on_expiry(policy))`

A duration starting at the moment of the launch

Timed access, where each launch is quit some time later

### Weekly Window

```
policy_for_range([1, 2, 3, 4, 5], "09:00", "17:00", ALLOWLIST, BLOCKLIST)
```

In the above example, without a time zone argument, every host reads the window on its own clock. The list of days `[1, 2, 3, 4, 5]` means Monday through Friday

### Weekly Window in a Named Time Zone

```
policy_for_range([0, 1, 2, 3, 4, 5, 6], "01:00", "05:00", "UTC", ALLOWLIST, BLOCKLIST)
```

With a time zone argument, every host reads the same calendar, so the window is the same four hours everywhere. The timezone argument can take “UTC”, offsets like “+05

”, or IANA form like “America/New\_York”. Use this form for anything that has to line up with a change window, a market close, or a batch job.

### Fixed Span

```
policy_for_range(timestamp("2026-09-14T00:00:00Z"), timestamp("2026-09-21T00:00:00Z"), ALLOWLIST, BLOCKLIST)
```

In the above example, the start and end timestamps are two absolute instants, so this form takes no day list and no time zone: a timestamp literal already carries its offset.

### Duration

```
policy_for_range(duration("30m"), kill_on_expiry(ALLOWLIST))
```

The window is `[now, now + d)`, so it is always open at the moment the expression runs. That is why this form takes no out of range policy, and why `kill_on_expiry()` is required: the form exists to set an expiry rather than to gate a decision.

## Window Arguments

### Days

Value

Meaning

`0` to `6`

Sunday through Saturday, matching CEL’s own `getDayOfWeek()`

`weekdays()`

Shorthand for `[1, 2, 3, 4, 5]`, Monday through Friday

`[]`

A window that never opens. The out of range policy applies at every moment

A day outside 0 to 6 is an error.

### Times of Day

`start` and `end` are 24-hour `"HH:MM"` strings, exactly five characters. `"9:00"` is rejected: write `"09:00"`.

-   **An end at or before the start crosses midnight.** The day list applies to the day the window *starts*, so `policy_for_range([5], "22:00", "06:00", ...)` opens Friday at 22
    
    and closes Saturday at 06
    
    .
-   **Equal start and end covers the whole day.** `"00:00", "00:00"` on all seven days is a window that is always open.
-   **The window is half open.** It includes the start minute and excludes the end minute, so back to back occurrences never overlap.

### Time Zones

The `tz` argument in `policy_for_range(...)` and `today(tz)` accepts these three values:

Value

Resolves to

`"local"`

The host’s own time zone, which is also the default when the form takes no `tz`

`"America/New_York"`

Any IANA name the host’s time zone database accepts, including `"UTC"`

`"+05:30"`

A fixed `[+-]HH:MM` offset from UTC

Anything else is refused in the rule editor. **Daylight saving:** A window follows the local clock, so a 09

to 17

window is still 09

to 17

after the clocks change. You never edit the rule for it.

## Policies in Each Slot

Both policy slots accept any [CEL return value](https://northpole.security/docs/workshop/rules/cel-guide#return-values), including `require_touchid_with_cooldown_minutes(N)` and `require_touchid_only_with_cooldown_minutes(N)`. The out of range slot does not have to block. For example, these three combinations cover most policies:

In range

Out of range

Effect

`ALLOWLIST`

`BLOCKLIST`

Available during the window, blocked outside it

`ALLOWLIST`

`require_touchid_with_cooldown_minutes(60)`

Available during the window, needs a fingerprint outside it

`ALLOWLIST`

`AUDIT`

Always available, and out of hours executions are flagged as audit matches

`kill_on_expiry()` is narrower. It accepts only policies that let a process start, because a blocked execution leaves nothing to quit:

`ALLOWLIST`, `AUDIT`, `SEATBELT`, `REQUIRE_TOUCHID`, `REQUIRE_TOUCHID_ONLY`, `require_touchid_with_cooldown_minutes(N)`, `require_touchid_only_with_cooldown_minutes(N)`.

The policy must be written out in the call. A computed policy, such as a ternary inside `kill_on_expiry()`, is refused.

## Quitting Processes When the Window Closes

Without `kill_on_expiry()`, a window governs new executions only. A process that started inside the window keeps running after the window closes, until the user quits it. Wrapping the in range policy closes that gap:

```
policy_for_range(weekdays(), "09:00", "17:00", kill_on_expiry(ALLOWLIST), BLOCKLIST)
```

### What Santa Records

Every execution the rule allows while the window is open is recorded against that rule, along with the deadline the window ends at. Nothing else is recorded: a process that started before the rule arrived, or that was allowed by a different rule, is never on the list. This is why windowed rules are non-cacheable, since a cached decision would let a process start unrecorded and so unquittable.

All the executions recorded under one rule share the **earliest** deadline recorded for it. A rule has one deadline, not one per launch, and a later launch never pushes it out. With a weekly or fixed window every execution ends at the same instant anyway. A countdown is where you notice it: launch the app at 10

under a 30 minute duration, launch it again at 10

, and both processes are quit at 10

.

### The Warning Notification

Santa warns the user before the deadline. The lead time is 10% of the window’s length, at least 5 minutes and at most an hour:

Window

Warning

8 hours

48 minutes ahead

1 hour

6 minutes ahead

30 minutes

5 minutes ahead

Under 5 minutes

At launch

The notification reads `"<App>" will quit at 5:00 PM.` and lists the application, its publisher, the user, and the window it came from, rendered as `9:00 AM to 5:00 PM, Mon through Fri` with the time zone appended when the rule named one. **More Details** adds the path, Signing ID, CDHash and parent process, and **Copy Details** puts all of it on the clipboard for a support ticket.

The banner appears once per deadline, and only when a recorded process is still running.

### At the Deadline

Santa sends `SIGTERM` to every recorded process, waits 5 seconds, then sends `SIGKILL` to whatever is still there. Each request names the recorded execution alone: its process group is deliberately not signaled, so a child it spawned survives unless that child was recorded under the rule in its own right.

### What Can Change A Pending Quit

-   **A window that is open again defers.** If the rule’s window is standing open at the deadline, which happens with a 24-hour window or two back to back occurrences, the deadline moves to the end of the occurrence standing there and nothing is quit. A Mac that slept through a deadline wakes into the same behavior.
-   **Pending quits survive a restart.** They are persisted, so a daemon restart or a reboot keeps the appointment. Santa runs anything that came due while it was down, and re-arms the rest.
-   **Editing or deleting the rule cancels its pending quit.** The rule is re-checked at the warning and again at the deadline. The next execution under the edited rule records a fresh deadline.
-   **Moving the clock backwards does not help.** Santa judges every window against a time that only ever moves forward, so a rolled back system clock cannot reopen a closed window or push out a pending quit.

## Examples

### Working Hours, Blocked Outside Them

```
policy_for_range(weekdays(), "09:00", "17:00", ALLOWLIST, BLOCKLIST)
```

### Working Hours, Touch ID Outside Them

Out of hours use stays possible with a person at the keyboard. The cooldown means one approval covers the next hour.

```
policy_for_range(weekdays(), "08:00", "18:00", ALLOWLIST, require_touchid_with_cooldown_minutes(60))
```

### Measure a Window Before Enforcing It

Both policy slots allow a process. Out of hours executions arrive as audit matches, which is the list of users a blocking version of this rule would have stopped. Swap `AUDIT` for `BLOCKLIST` when that list looks right.

```
policy_for_range(weekdays(), "09:00", "17:00", ALLOWLIST, AUDIT)
```

### One Maintenance Window for the Whole Fleet

```
policy_for_range([0, 1, 2, 3, 4, 5, 6], "01:00", "05:00", "UTC", ALLOWLIST, BLOCKLIST)
```

### Weekends Off

Equal start and end covers the whole day, so this blocks Saturday and Sunday and allows the rest of the week.

```
policy_for_range([0, 6], "00:00", "00:00", BLOCKLIST, ALLOWLIST)
```

### A Dated Exception

```
policy_for_range(timestamp("2026-09-14T00:00:00Z"), timestamp("2026-09-21T00:00:00Z"), ALLOWLIST, BLOCKLIST)
```

### A Shift That Ends with the Shift

Allowed through the working day, and anything still running is quit at 17

, with a warning 48 minutes earlier.

```
policy_for_range(weekdays(), "09:00", "17:00", kill_on_expiry(ALLOWLIST), BLOCKLIST)
```

### Timed Access, Counted from the Launch

The countdown starts at the execution and the process is quit when it runs out. Ask for a fingerprint first by wrapping a Touch ID policy instead:

```
policy_for_range(duration("30m"), kill_on_expiry(require_touchid_only_with_cooldown_minutes(30)))
```

### A Night Shift in a Fixed Offset

Opens at 22

Monday through Friday and closes at 06

the next morning, read at UTC+05

on every host.

```
policy_for_range([1, 2, 3, 4, 5], "22:00", "06:00", "+05:30", kill_on_expiry(require_touchid_with_cooldown_minutes(30)), BLOCKLIST)
```

### A Window That Applies to Some Executions Only

A ternary puts the window behind another test, so ordinary use is allowed at any hour and only the conditional execution (`--beta in args` in the example) is timed.

```
"--beta" in args ? policy_for_range(duration("30m"), kill_on_expiry(ALLOWLIST)) : ALLOWLIST
```

Similarly ternary conditionals work with any condition a CEL rule can test, such as the effective user:

```
euid == 0 ? policy_for_range(weekdays(), "09:00", "17:00", kill_on_expiry(ALLOWLIST), BLOCKLIST) : ALLOWLIST
```

## Validation

The rule editor checks the expression while you type and will not let you save one it refuses.

Expression

Why it is refused

`policy_for_range(duration("30m"), ALLOWLIST)`

The duration form exists to expire access, so it requires `kill_on_expiry()`

`kill_on_expiry(ALLOWLIST)` on its own

The wrapper is valid only as the in range policy of `policy_for_range()`

`kill_on_expiry(BLOCKLIST)`

A blocked execution leaves nothing to quit

`kill_on_expiry()` in the out of range slot

Only the in range policy can expire

`kill_on_expiry("-x" in args ? AUDIT : ALLOWLIST)`

The wrapped policy must be written out, not computed

`policy_for_range(...) && euid == 0`

The call returns a decision, not a boolean. Use a ternary

One `policy_for_range()` inside another’s arguments

CEL evaluates every argument, so the inner window would record a quit for executions it never decided. Use a ternary

`"9:00"`, `"24:00"`, `"09:60"`, `[7]`, `"Mars/Olympus"`

Malformed time, day or time zone

One rule holds one window. To combine a window with anything else, put the call in a branch of a ternary, as in the last two examples above.

## Cacheability

Santa normally caches a CEL decision per binary. Any expression that calls `policy_for_range()` is marked non-cacheable and is re-evaluated on every execution, which is what lets a window turn over and what makes the recording behind `kill_on_expiry()` complete. `now()` and `today()` have the same effect for the same reason.

The cost is one CEL evaluation per execution of the binaries the rule covers, so prefer a narrow identifier over a broad one on hot paths. See [Cacheability](https://northpole.security/docs/workshop/rules/cel-guide#caching) in the CEL Guide.

## Troubleshooting

The daemon logs every step of a pending quit:

```
/usr/bin/log stream --level debug --predicate 'sender == "com.northpolesec.santa.daemon"'
```

Log line

Meaning

`Recorded timed rule kill for <id>: quitting at <t>, warning at <t>`

The first execution under the rule was recorded

`Recorded execution under timed rule kill for <id> (pid …)`

A later execution joined the same deadline

`Sending timed rule kill banner for <app> (<id>), quitting at <t>`

The warning notification went to the GUI

`Timed rule kill firing for <id>: N recorded process(es)`

The deadline arrived and N processes are being quit

`Timed rule kill for <id> deferred: its window is open again until <t>, nothing quit`

The window was standing open at the deadline

`Timed rule kill for <id> cancelled: the rule is gone`

The rule was deleted before the deadline

`Timed rule kill for <id> cancelled: the rule changed (rule id X -> Y)`

The rule was edited before the deadline

`Ignoring timed rule kill for <id>: no server-assigned rule id`

The rule was added locally, so no quit can be recorded

`Restored N pending timed rule kill(s)`

Pending quits were reloaded at daemon start

## Best Practices

-   **Audit before you enforce.** Ship the rule with `AUDIT` in the out of range slot, read the events for a week, then change it to `BLOCKLIST`.
-   **Pick the time zone deliberately.** Leave `tz` off for anything that means “the working day”, and name a zone for anything that has to be the same instant everywhere.
-   **Reach for Touch ID before a hard block.** An out of range `require_touchid_with_cooldown_minutes(N)` keeps the exception path open, and every use is still recorded.
-   **Warn people before you quit their work.** `kill_on_expiry()` on a short window gives a short warning. A window of an hour or more gives users real notice.
-   **Scope by code signing identity.** As with any execution rule, a CDHash, Signing ID or Team ID identifier is much harder to sidestep than a binary path.
-   **Roll out by tag.** Scope the rule to one tag first. Hosts on Santa older than 2026.8 will silently not receive it, so confirm your fleet’s versions before you rely on a window for coverage.

## Sitemap

- [Home](https://northpole.security/index.md)
- [Workshop](https://northpole.security/workshop.md)
- [Santa](https://northpole.security/santa.md)
- [Features](https://northpole.security/features.md)
- [Cookbook](https://northpole.security/cookbook.md)
- [Docs](https://northpole.security/docs.md)
- [Blog](https://northpole.security/blog.md)
- [Glossary](https://northpole.security/glossary.md)
- [About](https://northpole.security/about.md)
- [Contact](https://northpole.security/contact.md)
