Scheduled runs¶
This document specifies Milestones 11, 19, 20, and 23 and the authorized native schedule browser. The engineering plan states the requirement; this document states the mechanism. It is subordinate to engineering-plan.md, and it reuses rather than replaces the durable run queue, run loop, policy engine, event log, and HTTP boundary. ADR-0059 records the architectural decisions for the control plane, ADR-0072 records the original one-time conversational bridge, ADR-0073 records the calendar-recurrence extension, ADR-0075 records the transport-only Apple inspection surface, and ADR-0080 records the conversational lifecycle extension. ADR-0088 records the owner's 2026-09-04 expansion of that extension to conversational content and cadence updates. ADR-0089 records the owner's 2026-09-10 authorization of a current/history split, thirty-day terminal retention, and conditional time-zone presentation.
The scheduling entry condition is satisfied: PostgreSQL-backed on-demand runs, leases, fencing, checkpoints, recovery, cancellation, and the public run API are complete through Milestone 9. Milestone 11 is separately authorized while Milestone 10 remains in progress. Its gates may become green independently, but the verified gate ceiling cannot advance past an incomplete earlier milestone.
Scope¶
Milestone 11 delivers a small scheduling control plane that materializes ordinary durable runs. It includes:
- one-time, daily, and weekly schedules;
- IANA time-zone handling for recurring schedules;
- immutable schedule revisions and an occurrence ledger;
- create, list, read, update, pause, resume, cancel, and occurrence-list HTTP operations;
- current-authority resolution when an occurrence fires;
- deterministic misfire, overlap, and daylight-saving-time behavior;
- asynchronous queue priority, per-tenant admission, and cost controls;
- durable audit events, metrics, and offline result retrieval through the occurrence-to-run link.
Milestone 11 does not include arbitrary cron expressions, monthly calendar
rules, dependency graphs, workflow DAGs, user-selectable catch-up algorithms,
push notifications, general-purpose subagents, or a second queue technology.
It deliberately did not make a schedule a tool the model may create; schedule
management was an authenticated application surface. Milestone 19, authorized
by the owner on 2026-08-24 and recorded in ADR-0072, adds the narrow
model-callable creation surface specified below without changing the Milestone
11 control plane or its completed gates. Milestone 20, authorized by the owner
on 2026-08-27 and recorded in ADR-0073, adds monthly and yearly calendar rules
and widens conversational creation to daily, weekly, monthly, and yearly
schedules. Arbitrary cron or RFC 5545 input, interval multipliers, dependency
graphs, workflow DAGs, and continuous-session recurrence remain outside the
closed extension. Milestone 23, authorized by the owner on 2026-09-02 and
recorded in ADR-0080, brings the existing list, pause, resume, and cancel
service operations into the governed model-tool pipeline. The owner expanded
Milestone 23 on 2026-09-04 through ADR-0088: schedule.update adds a closed,
approval-gated title, instruction, and cadence patch while the application
service preserves hidden authority and execution fields. Occurrence and run
listing and user- or model-initiated hard deletion remain outside that
extension. ADR-0089 separately authorizes maintenance deletion of terminal
schedule-owned state after its retention window.
The owner authorized a native Apple schedule browser on 2026-08-29. It reuses the existing Milestone 11 list and point-read routes. On 2026-09-10 the owner authorized its current/history split and the corresponding terminal-retention policy under ADR-0089. The client contract is specified below.
The boundary: a scheduler creates runs; it does not execute them¶
scheduled_for already prevents the run queue from claiming work before a UTC
instant. It is a queue eligibility field, not a recurrence definition. The
Milestone 11 scheduler owns recurrence and creates one ordinary run when an
occurrence becomes due:
schedule revision
|
v
schedule worker -- one transaction --> occurrence + session + queued run
|
v
existing PostgreSQL run queue
|
v
existing durable run worker
The created run follows every existing rule. It is leased, checkpointed, fenced, budgeted, cancellable, recoverable, and audited by the same components as an on-demand run. The scheduler never calls a model or tool, holds a run lease, resumes a checkpoint, or decides a policy outcome.
This yields three load-bearing invariants:
- A committed occurrence either has its committed run, session, and seed events or has none of them.
- A scheduled execution has no weaker authority, policy, budget, deadline, or audit record than the equivalent on-demand execution.
- Queue correctness remains in PostgreSQL.
LISTEN/NOTIFYmay reduce latency, but losing a notification cannot lose an occurrence or a run.
Domain model¶
Schedule¶
Schedule is the mutable control record:
class ScheduleState(StrEnum):
ACTIVE = "ACTIVE"
PAUSED = "PAUSED"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"
class Schedule(BaseModel):
id: UUID
tenant_id: str
principal_id: str
state: ScheduleState
pause_reason: str | None
current_revision: int
next_fire_at: datetime | None
consecutive_failures: int
created_at: datetime
updated_at: datetime
pause_reason is user or failure_limit when state is PAUSED, and null in
every other state. A one-time schedule becomes COMPLETED after its sole
occurrence is materialized or recorded missed. CANCELLED is terminal.
Immutable revision¶
Every create or update writes a complete ScheduleRevision. An occurrence
references the exact revision it used. Updating a schedule never rewrites a
past occurrence or a revision:
class CadenceKind(StrEnum):
ONCE = "ONCE"
DAILY = "DAILY"
WEEKLY = "WEEKLY"
MONTHLY = "MONTHLY"
YEARLY = "YEARLY"
class ScheduleRevision(BaseModel):
schedule_id: UUID
revision: int
title: str
instruction: str
agent_id: UUID
agent_version: str
policy_profile: str
requested_scopes: frozenset[str]
browser_profile_id: UUID | None = None
limits: RunLimits
run_timeout_seconds: int
cadence: dict[str, object]
timezone: str | None
misfire_grace_seconds: int
max_consecutive_failures: int
created_by_principal_id: str
created_at: datetime
instruction is user-authored input and enters the occurrence session as a
normal USER message. It is not system text. It may contain sensitive task
data, so API responses return it only from a point read authorized by
schedule.read; list responses return metadata and a bounded preview. It is
never logged. Credentials and authentication tokens are rejected at schedule
validation by the existing secret scanner and are never valid revision data.
agent_version, policy_profile, and limits are pinned. The profile must
equal the profile on the pinned AgentSpec at create and update time. The
materializer repeats that check before creating a run, so a missing or
inconsistent version produces a configuration failure rather than silently
changing behavior.
Scheduled runs require finite bounds. run_timeout_seconds, max_steps,
max_model_calls, max_tool_calls, and max_cost must all be present and
positive. At materialization, deadline_at is the materialization time plus
run_timeout_seconds; the nominal firing instant remains separately audited.
Tenant configuration supplies ceilings for each field.
Occurrence¶
The occurrence ledger says what the scheduler decided. It does not duplicate the run state machine:
class OccurrenceDisposition(StrEnum):
MATERIALIZED = "MATERIALIZED"
MISSED = "MISSED"
SKIPPED_OVERLAP = "SKIPPED_OVERLAP"
AUTHORIZATION_FAILED = "AUTHORIZATION_FAILED"
CONFIGURATION_FAILED = "CONFIGURATION_FAILED"
class ScheduleOccurrence(BaseModel):
id: UUID
schedule_id: UUID
schedule_revision: int
nominal_fire_at: datetime
disposition: OccurrenceDisposition
session_id: UUID | None
run_id: UUID | None
reason_code: str | None
authority_version: str | None
materialized_at: datetime | None
links_erased_at: datetime | None
created_at: datetime
MATERIALIZED requires session_id, run_id, materialized_at, and the
authority version while links_erased_at is null. After governed session
erasure, MATERIALIZED instead requires both identifiers to be null and
links_erased_at to be present; materialized_at and the non-secret authority
version remain as audit facts. Partial erasure states are invalid. Every other
disposition forbids the identifiers and links_erased_at and requires a stable
reason code. Run progress and outcome are read by joining the linked run rather
than copied onto the occurrence.
The database enforces UNIQUE(schedule_id, nominal_fire_at). This is the
occurrence idempotency key. It prevents two schedule workers, a retry after an
unknown commit, or a process restart from materializing the same nominal
instant twice.
Cadence and civil time¶
All persisted instants are timezone-aware UTC. Recurring definitions also keep their IANA zone and local civil time because UTC alone cannot preserve “09:00 America/Los_Angeles” across daylight-saving transitions.
The supported cadence payloads are closed:
ONCE {"at": RFC3339 timestamp with an offset}
DAILY {"local_time": "HH:MM[:SS]", "timezone": IANA name}
WEEKLY {"local_time": "HH:MM[:SS]", "weekdays": [1..7],
"timezone": IANA name}
MONTHLY {"local_time": "HH:MM[:SS]", "days_of_month": [1..31],
"last_day": true|false, "timezone": IANA name}
YEARLY {"local_time": "HH:MM[:SS]",
"dates": [{"month": 1..12, "day": 1..31}, ...],
"timezone": IANA name}
ISO weekday 1 is Monday and 7 is Sunday. Weekly weekdays are unique and stored
in ascending order. Monthly numbered days are unique and stored in ascending
order; at least one numbered day or last_day = true is required. A numbered
day absent from a month is skipped, while last_day explicitly selects that
month's actual final day. If both select the same date, it fires once. Yearly
month/day pairs are unique and sorted. February 29 is valid and fires only in
leap years; a pair impossible in every Gregorian year, such as April 31, is
invalid. zoneinfo from the Python standard library is the time-zone authority;
the runtime records the installed tzdata version in startup diagnostics.
Civil-time resolution is deterministic:
- An ambiguous local time during a fall-back transition chooses the earlier UTC instant and fires once.
- A nonexistent local time during a spring-forward transition advances to the first valid local instant on that date and fires once.
- A recurrence is calculated from the declared civil rule, never by adding 24 hours or seven days to the preceding UTC instant.
- Monthly and yearly lookup enumerates only a bounded set of candidate months or years; coalesced counts use Gregorian calendar arithmetic rather than one loop per missed occurrence.
next_fire_atis cached state. The immutable cadence plus the last recorded nominal instant can reconstruct it, and a repair command may replace a wrong cache only after emittingschedule.next_fire_repaired.
The recurrence calculator is pure and accepts a Clock-supplied reference
instant. Ambient wall-clock reads are forbidden.
Misfires and downtime¶
One scheduler scan may materialize at most one occurrence per schedule. The
calculator finds the latest nominal instant at or before now without
iterating through an unbounded number of missed dates.
misfire_grace_seconds is mandatory, positive, and capped by tenant policy.
For a due schedule:
- If the latest due nominal instant is within the grace window, that instant
is the candidate. Older due instants are coalesced and represented by one
schedule.misfires_coalescedevent containing onlyfirst_nominal_at,last_nominal_at, andcount. - If the latest due instant is outside the grace window, no run is created.
One
MISSEDoccurrence is recorded for that latest instant, the same bounded coalescing event records older instants, andnext_fire_atadvances to the first future instant. - A one-time schedule outside its grace window records
MISSEDand becomesCOMPLETED.
Pausing stops materialization. Resuming computes the first future nominal
instant strictly after the resume time and never backfills time spent paused.
The pause and resume events preserve the previous and new next_fire_at.
Overlap¶
Milestone 11 has one overlap policy: do not overlap occurrences from the same
schedule. A schedule has an in-flight occurrence when its most recent
MATERIALIZED occurrence links to a non-terminal run.
If another occurrence becomes due while one is in flight, no run is created.
The latest due nominal instant receives SKIPPED_OVERLAP; older due instants
are coalesced by the same bounded rule, and the next future instant is stored.
The existing run continues unchanged. An overlap is not a cancellation and not
a run failure.
Different schedules may run concurrently, subject to tenant admission and the async worker pool. A later milestone may add a queue-one overlap policy only if it also defines queue bounds and cost behavior; Milestone 11 does not.
Identity, authorization, and policy¶
A schedule stores identity, never credentials. The creator must hold all three new exact platform scopes as appropriate:
schedule.write authorizes create, update, pause, and resume.
schedule.cancel authorizes the terminal cancel operation. Scope names have no
hierarchy or wildcard behavior, consistent with the existing vocabulary.
Creation and update require requested_scopes to be a subset of the caller's
current scopes. Firing is a new submission boundary, so it must not trust the
old snapshot. A SchedulePrincipalDirectory resolves the current principal by
tenant_id and principal_id, returning a monotonic authority_version. The
adapter must be backed by authoritative local configuration or the same durable
identity store used for requests; it may not call an external identity provider
while holding the schedule transaction.
Materialization succeeds only when:
- the principal still exists and is enabled;
- every requested scope is still granted;
- the pinned agent version exists;
- the pinned policy profile still matches that agent version;
- tenant schedule, concurrency, and cost admission permits the occurrence.
Failure is closed. The scheduler records AUTHORIZATION_FAILED or
CONFIGURATION_FAILED, emits an audit event with a stable reason code, creates
no session or run, advances the cadence, and increments the schedule's
consecutive-failure counter. It never silently narrows scopes or substitutes an
agent version, policy profile, or looser budget.
The created run snapshots exactly requested_scopes, not every scope the
principal currently holds. It also records authority_version and the schedule
and occurrence identifiers in the run.queued payload. Raw bearer tokens,
cookies, API keys, and identity-provider assertions appear in no schedule,
revision, occurrence, event, or run field.
Explicit website binding¶
The authenticated complete definition may include browser_profile_id
(ADR-0150). A non-null UUID requires browser.profile.read on the caller and
in requested_scopes, and a tenant/principal-scoped read of a READY profile.
Missing, foreign or non-ready profiles fail definition validation. Old
revisions decode with no binding, and unbound definitions keep their original
idempotency hash. No credential or profile material is stored.
Materialization copies the immutable revision's UUID into the reserved session
browser binding. The scheduler's database authority is unchanged. Before the
first model call, the run worker checks the profile's ownership, readiness and
requested scope, and that the plan supplies browser.navigate and
browser.observe. On every execution attempt, including resume, composition
also requires a session-bound hosted provider or a fixed hosted provider whose
profile UUID matches the schedule pin. A mismatched fixed profile is
tool.browser.profile_unavailable; other provider modes are
tool.browser.provider_unavailable. These checks precede model work and pending
tool dispatch. Unavailable access fails the run with a stable reason code,
so ordinary outcome accounting, failure limits and notifications apply. The
provider revalidates again before browser dispatch. The schedule's finite
limits and deadline remain pinned; it does not inherit the interactive browser
budget or a source chat's grants.
The full-definition HTTP update can bind or unbind future occurrences with the
ordinary expected-revision check. Content-only schedule.update preserves the
binding. schedule.create version 1.2.0 accepts use_website=true to select the
current chat's trusted website binding and request browser.profile.read.
Its approval presentation identifies the profile and origins; missing binding
fails as schedule.browser_profile_required, and normal creation validates
ownership, READY state and current authority. The model cannot supply a profile
UUID or arbitrary scopes. Version 1.1.1 remains for already pinned runs.
Browser actions retain their existing approval rules,
including ADR-0168's bounded-scroll permission in the default policy. Scheduled
feed reads can scroll without an interactive grant. ADR-0209 also permits
constrained ordinary interactions in the selected profile, based on the durable
scheduler seed. Other actions require ordinary approval or an applicable grant.
The native schedule detail offers that update as its Website access picker
(ADR-0154).
Sessions, context, and results¶
Each materialized occurrence creates a dedicated session and exactly one run. This is mandatory rather than policy-selectable in Milestone 11. It avoids the one-active-run-per-session constraint, prevents an overdue occurrence from blocking an unrelated occurrence, and bounds conversational history.
The session stores schedule_id, schedule_revision, occurrence_id, and
nominal_fire_at in metadata. Its agent ID and version match the revision. The
instruction is appended as the session's first user event and seeds the run in
the same transaction. Its scheduler actor marks it as durable model context,
not a chat message: public transcript reads and run streams omit that seed
event while retaining the completed assistant response. The authorized
schedule point read remains the surface for inspecting the complete
instruction. This presentation rule applies to every cadence and does not
remove the instruction from context, persistence, audit, or title generation.
schedule_id is a reserved platform metadata key. Public session creation
rejects a caller-supplied value; ScheduleMaterializer is the trusted path that
writes it, so downstream context planning can use its presence as scheduled-run
provenance.
Cross-occurrence continuity comes from the principal's governed long-term memory and knowledge stores, not from reusing a conversation session. Provider opaque continuation state never crosses occurrences.
A revision may pin positive final-synthesis reserves within its finite
RunLimits. The ordinary runtime applies those reserve fields to the
materialized run regardless of run kind (ADR-0078): once research reaches a
reserved step, model-call, or cost boundary, the next request must synthesize
from evidence already in context and cannot call another tool. All-zero reserve
fields retain the existing unreserved behavior. The reserve does not change the
revision's hard total or the schedule admission reservation.
GET /v1/schedules/{id}/occurrences is the durable offline inbox. A
materialized row embeds the existing run view or links to GET /v1/runs/{id}.
Clients need not have been online when the occurrence fired. Push, email, and
mobile notification delivery remain a separate surface because the current
NotificationService seam has no delivery contract.
State transitions¶
Schedule transitions are closed:
ACTIVE -> ACTIVE update creates revision N+1
ACTIVE -> PAUSED user pause or failure limit
ACTIVE -> COMPLETED one-time occurrence decided
ACTIVE -> CANCELLED explicit cancel
PAUSED -> PAUSED update creates revision N+1
PAUSED -> ACTIVE explicit resume
PAUSED -> CANCELLED explicit cancel
COMPLETED -> (none)
CANCELLED -> (none)
Every mutation supplies expected_revision. A stale value returns a conflict
carrying the current revision and changes nothing. Pause, resume, and cancel are
idempotent when repeated against the already reached state; an incompatible
terminal transition returns a conflict.
Canceling a schedule prevents future materialization. It does not cancel a run
already created. The caller may separately invoke the existing run cancel
endpoint with run.cancel; this separation prevents schedule.cancel from
becoming indirect authority to cancel arbitrary execution.
After a linked run reaches COMPLETED, a post-run hook resets
consecutive_failures to zero. FAILED, authorization failure, configuration
failure, and a missed occurrence increment it. User-cancelled runs and overlap
skips do not. Reaching max_consecutive_failures pauses the schedule with
pause_reason = failure_limit and emits schedule.auto_paused. The hook is
idempotent on occurrence ID. Its schedule.run_accounted event records the
terminal run status, the resulting failure count, and failure: null unless
the run failed, otherwise the failure reason, error class, and any sanitized
provider, provider_code, http_status, and provider_parameter the run
recorded (ADR-0109). The failure message and other details stay with the run,
so the classification survives session erasure without retaining run content.
Materialization transaction¶
ScheduleWorker.run_once() performs bounded batches. For each due schedule it
opens one short transaction and:
- Selects and locks the schedule row with
FOR UPDATE SKIP LOCKED, requiringstate = ACTIVEandnext_fire_at <= now. - Loads the immutable current revision and calculates the latest due nominal instant and first future instant.
- Resolves current authority and admission from local authoritative state.
- Checks overlap, grace, configuration, scopes, and budgets.
- Inserts the unique occurrence disposition.
- For
MATERIALIZED, creates the dedicated session, appendssession.createdand the context-only scheduler-authored user message, creates the priority-10QUEUEDrun withscheduled_for = now, seeds its checkpoint, and appendsrun.queued. - Advances
next_fire_at, applies the one-time terminal transition where required, and appends schedule audit events. - Commits, then sends a best-effort queue notification.
The transaction performs no model, tool, network, object-store, or external identity-provider I/O. A crash before commit leaves the schedule due and no partial occurrence. A crash after commit leaves one complete occurrence and run; a retry encounters the uniqueness constraint and returns the committed row.
The scheduler sleeps until the earlier of the next known next_fire_at and a
bounded fallback poll. Schedule create, update, and resume may send a best-effort
notification to wake it. Correctness depends only on the table scan.
Capacity, fairness, and cost¶
Scheduled runs use async priority 10. They never use interactive priority 0. The existing reserved-capacity rule remains authoritative: an async backlog must not consume the worker slots reserved for interactive work, and interactive work must not consume the minimum async capacity that guarantees schedules eventually progress.
Admission is checked at materialization, before a run is created:
- a configured maximum of active scheduled runs per tenant;
- a configured maximum of materializations per tenant per minute;
- a configured daily and monthly scheduled-cost ceiling;
- the per-run limits pinned on the revision.
Admission denial records no run. A concurrency denial is transient: the
schedule remains due and is retried after a bounded admission backoff without
creating an occurrence. Rate or rolling-cost denial records a missed occurrence
with its stable reason and advances cadence, so a cost ceiling cannot create an
unbounded queue. All counters use committed run usage; reservations charge the
revision's max_cost while a scheduled run is active and reconcile on terminal
usage.
Public API¶
Milestone 11 adds these routes:
POST /v1/schedules
GET /v1/schedules
GET /v1/schedules/{schedule_id}
PATCH /v1/schedules/{schedule_id}
POST /v1/schedules/{schedule_id}/pause
POST /v1/schedules/{schedule_id}/resume
DELETE /v1/schedules/{schedule_id}
GET /v1/schedules/{schedule_id}/occurrences
POST /v1/schedules requires Idempotency-Key. Schedule request idempotency is
stored separately from run-submission idempotency under the composite key
(tenant_id, principal_id, key), with a request hash and resulting schedule
ID. Same key and same hash returns the existing schedule; same key and different
hash is a conflict. Conversational schedule.update reuses this ledger with a
canonical hash over its stable ID, expected revision, and explicit patch. The
idempotency row commits with the new immutable revision, so recovery after an
unknown commit returns the existing schedule instead of applying the patch
again.
PATCH, pause, resume, and delete require expected_revision and apply the
state rules above. List routes use opaque stable cursors and bounded limits.
GET /v1/schedules also accepts an optional repeated state parameter drawn
from the closed schedule-state enum. Omission lists every retained state for
backward compatibility; supplied states are applied in the principal-scoped
repository query before cursor comparison and limiting.
Cross-tenant or cross-principal access returns the same not-found envelope as
the existing API. Every route declares its required schedule scope in OpenAPI.
Validation rejects unknown cadence fields, naive datetimes, invalid IANA zones,
duplicate or out-of-range weekdays, duplicate or out-of-range monthly days,
empty monthly selectors, duplicate or impossible yearly dates, empty yearly
selectors, non-positive bounds, a grace or timeout above tenant ceilings,
unknown scopes, mismatched agent policy, and secret-like instructions. Errors
use the existing envelope and stable reason codes under schedule.*.
Native Apple schedule browser¶
The browser is a presentation of the authenticated principal's existing schedule control-plane records, not a second schedule service. A calendar entry beside Memory in the native sidebar opens a list/detail sheet. Presentation reloads page one from the server; the client holds only a discardable view cache and never computes a schedule's next occurrence locally.
The default Current section calls GET /v1/schedules with repeated ACTIVE and
PAUSED state filters, a bounded limit, and the server's opaque cursor. The
user-selected Recent History section uses the same route with COMPLETED and
CANCELLED filters. Each row contains the title, a text-labeled state, a
human-readable cadence summary, next_fire_at when present, and the
server-provided instruction_preview. The preview remains bounded and is
never promoted to the complete instruction.
Following a row calls GET /v1/schedules/{schedule_id}. Only that authorized
point read supplies the complete instruction. Detail displays the server's
current record and revision: state and pause reason, next firing, cadence,
revision number, complete instruction, requested scopes, pinned agent and
policy identifiers, finite execution limits, failure policy, and lifecycle
timestamps. If the schedule is removed or becomes inaccessible between the
list and point reads, the detail shows the ordinary not-found failure and a
retry affordance; it does not reinterpret the result as version skew.
Detail also shows the schedule's website access, the browser's only write
(ADR-0154). For an ACTIVE or PAUSED schedule a picker offers None and each of
the owner's ready browser profiles, labelled by the host names of its
allowed origins; a bound profile that is not ready, or is no longer listed,
stays visible with its state. A choice performs a fresh point read, then the
full-definition update with that read's current_revision and the revision's
own fields less schedule_id, revision, timezone,
created_by_principal_id, and created_at. Only browser_profile_id and the
browser.profile.read requested scope change, and fields the client does not
model are sent back unchanged. A revision conflict is read again and retried
once; any other refusal leaves the server's binding displayed with the reason.
Terminal and unknown states show the binding read-only. Overlapping sign-in
loads apply results or failures only for the newest request.
The client models state and cadence kind as raw strings with typed known-case accessors. Any unknown value returned by a compatible server renders by replacing separators with spaces and capitalizing the result. Known cadence summaries are:
- ONCE: the absolute
atinstant; - DAILY: the local time;
- WEEKLY: ISO weekday names and local time;
- MONTHLY: numbered days, explicit last day, and local time;
- YEARLY: month/day selectors and local time.
For every recurring cadence, the summary appends the stored IANA zone only when it differs from the device's current zone. This is presentation only: the wire model retains the zone and the server remains authoritative for recurrence and daylight-saving-time behavior.
List pagination shares the native client's established safeguards: duplicate schedule IDs are ignored, a repeated cursor terminates paging, stale page responses cannot overwrite a newer reload, and a later-page failure retains the already loaded rows with an inline retry. A 404 or 405 from the list route means schedule browsing is unavailable on that server. The same statuses from a point read retain their ordinary HTTP meaning.
The surface is read-only except for website-access binding on ACTIVE or PAUSED
schedules (ADR-0154). Reading needs schedule.read, listing sign-ins needs
browser.profile.read, and saving the binding needs schedule.write plus
browser.profile.read when bound. It has no other create, update, pause, resume,
cancel, delete, occurrence, or run-history control. Swift transport, model, view-model, structure,
and in-process iOS navigation tests are the acceptance evidence under
ADR-0049's native verification contract. The current/history extension adds no
route or scope and does not alter the historical milestone gate counts.
Scheduled conversations in the native sidebar¶
Every occurrence runs in a session of its own, so a recurring schedule adds a conversation to the sidebar on each firing. Folders never hold these sessions (thread-folders.md keeps folders to chat conversations), so the native sidebar groups them by schedule instead, as a presentation of data the session index already carries (ADR-0113).
The history cache records each session's schedule_id metadata beside its
title; the server's value wins on every reconciliation, and a value that is
not a UUID string is treated as absent. A schedule with two or more cached
sessions renders as one collapsible group in a Scheduled section, which
follows the folders and precedes the unfiled history. A schedule with a
single cached session stays in the history, where it would have been. The
group's label is the title of its most recent session, since the materializer
titles each session with the pinned revision's title; its count is the number
of cached sessions; and its rows keep the history's activity order, so the
age line distinguishes one firing from the next. Groups order by their most
recent session's activity, newest first, with the schedule identifier as the
tie-break.
Groups are collapsed until the owner expands one. Which groups are expanded
is device state, remembered across launches and never sent to the server, and
a new firing never reopens a closed group. Grouping depends on no folder
route or flag: it appears whether or not the server offers folders, and a
server without scheduling simply sends no schedule_id. A session that
carries schedule_id offers no move-to-folder control anywhere in the
sidebar, because the move route refuses it with session_not_chat.
The grouping adds no route, scope, event, or server state; the schedule record is not fetched to label a group, so a removed or inaccessible schedule still groups its remaining sessions. Swift model and view-model tests are the acceptance evidence under ADR-0049's native verification contract, and the extension does not alter the historical milestone gate counts.
Events and audit¶
Schedule lifecycle events are process events because a schedule exists outside any session:
schedule.created
schedule.updated
schedule.paused
schedule.resumed
schedule.cancelled
schedule.completed
schedule.auto_paused
schedule.occurrence.materialized
schedule.occurrence.missed
schedule.occurrence.skipped_overlap
schedule.occurrence.authorization_failed
schedule.occurrence.configuration_failed
schedule.misfires_coalesced
schedule.next_fire_repaired
Every event carries schedule_id, revision, tenant, principal, actor, event
time, and the previous and next state where applicable. Occurrence events also
carry occurrence ID and nominal fire time; a materialized event carries session
and run IDs. Events carry no instruction, credential, raw policy content, or
secret-like validation match.
The session and run event logs retain their existing ownership and schema. A materialized run adds schedule linkage to its creation payload but otherwise emits the normal sequence.
Ports¶
The application layer owns these provider-neutral ports:
class ScheduleRepository(Protocol):
async def create(self, schedule: Schedule, revision: ScheduleRevision) -> Schedule: ...
async def get(self, schedule_id: UUID, principal: Principal) -> Schedule: ...
async def list(
self,
principal: Principal,
page: Page,
states: frozenset[ScheduleState] | None = None,
) -> Page[Schedule]: ...
async def purge_terminal(
self, tenant_id: str, before: datetime, limit: int
) -> int: ...
async def mutate(self, command: ScheduleCommand) -> Schedule: ...
async def due(self, now: datetime, limit: int) -> list[UUID]: ...
async def next_fire_at(self) -> datetime | None: ...
class ScheduleOccurrenceRepository(Protocol):
async def insert(self, occurrence: ScheduleOccurrence) -> ScheduleOccurrence: ...
async def list(self, schedule_id: UUID, principal: Principal, page: Page) -> Page[ScheduleOccurrence]: ...
class SchedulePrincipalDirectory(Protocol):
async def current(self, tenant_id: str, principal_id: str) -> AuthoritySnapshot: ...
class ScheduleAdmissionController(Protocol):
async def check(
self, tenant_id: str, revision: ScheduleRevision, now: datetime
) -> ScheduleAdmissionDecision: ...
class RecurrenceCalculator(Protocol):
def due(self, revision: ScheduleRevision, now: datetime) -> DueCalculation: ...
The concrete PostgreSQL repositories join the existing unit of work so occurrence, session, run, checkpoint, and schedule writes share one transaction. The recurrence calculator is pure domain logic and has no I/O. The configured identity adapter resolves the deployment's authoritative principal and exposes a content-derived authority version; the same adapter is used by the isolated production scheduler role, so changed roles or scopes produce a new version without storing a request credential.
Admission returns one of three closed outcomes. ALLOW continues the
transaction, RETRY leaves the schedule due and writes no occurrence, and
REJECT supplies a stable rate or cost reason that is recorded as MISSED.
The allow-all controller is development-only. The PostgreSQL controller takes a
tenant-scoped transaction advisory lock, then computes concurrency, rate,
active maximum-cost reservations, and rolling daily and monthly scheduled cost
from authoritative committed rows. This makes two different schedules racing
for the last tenant slot serialize before either creates a run.
ScheduleWorker.run_once() reads one deterministic bounded due-ID batch and
then invokes the materializer separately for each definition, so one corrupt
definition cannot roll back or prevent its siblings. Its loop waits for the
earlier of the repository's next active next_fire_at and the fallback poll;
an already-due definition uses the bounded admission backoff. Cancellation is
propagated, while definition and scan failures are logged and isolated. The
sole composition root exposes the in-process worker for development and a
separate least-privilege PostgreSQL builder for production. The production
builder validates token-mode configured identity but neither loads nor receives
the API bearer token or any execution-provider credential.
When Gmail is active, the scheduler environment mirrors
AGENT_EMAIL_ENABLED and, for named accounts, the non-secret
GMAIL_ACCOUNTS_FILE path. Its credential-minimized loader validates the
manifest shape and account ids, derives the exact mcp.gmail_*.use scopes for
the current-authority snapshot, and never opens a referenced OAuth credential
file. Legacy single-account mode derives only the original three Gmail scopes
from the enabled flag. Gmail scopes therefore stay synchronized with account
renames and removals without adding them to the closed AUTH_SCOPES platform
vocabulary or placing credentials in the scheduler role.
No in-memory scheduler queue is introduced. In-memory repositories exist only to run application and property tests; production startup refuses scheduling without PostgreSQL, a durable principal directory, and a non-development worker topology.
Persistence¶
Milestone 11 adds four tables:
schedules
id UUID PRIMARY KEY
tenant_id TEXT NOT NULL
principal_id TEXT NOT NULL
state TEXT NOT NULL
pause_reason TEXT NULL
current_revision INTEGER NOT NULL
next_fire_at TIMESTAMPTZ NULL
consecutive_failures INTEGER NOT NULL DEFAULT 0
created_at TIMESTAMPTZ NOT NULL
updated_at TIMESTAMPTZ NOT NULL
schedule_revisions
schedule_id UUID NOT NULL REFERENCES schedules(id)
revision INTEGER NOT NULL
definition JSONB NOT NULL
created_by_principal_id TEXT NOT NULL
created_at TIMESTAMPTZ NOT NULL
PRIMARY KEY (schedule_id, revision)
schedule_occurrences
id UUID PRIMARY KEY
schedule_id UUID NOT NULL REFERENCES schedules(id)
schedule_revision INTEGER NOT NULL
nominal_fire_at TIMESTAMPTZ NOT NULL
disposition TEXT NOT NULL
session_id UUID NULL REFERENCES sessions(id) DEFERRABLE INITIALLY DEFERRED
run_id UUID NULL REFERENCES runs(id) DEFERRABLE INITIALLY DEFERRED
reason_code TEXT NULL
authority_version TEXT NULL
materialized_at TIMESTAMPTZ NULL
links_erased_at TIMESTAMPTZ NULL
created_at TIMESTAMPTZ NOT NULL
UNIQUE (schedule_id, nominal_fire_at)
schedule_idempotency_keys
tenant_id TEXT NOT NULL
principal_id TEXT NOT NULL
key TEXT NOT NULL
request_hash TEXT NOT NULL
schedule_id UUID NOT NULL REFERENCES schedules(id)
created_at TIMESTAMPTZ NOT NULL
PRIMARY KEY (tenant_id, principal_id, key)
Indexes support (state, next_fire_at) for active due scans,
(tenant_id, principal_id, updated_at, id) for listing, and
(schedule_id, nominal_fire_at DESC) for occurrence history. A partial
(tenant_id, updated_at, id) index over COMPLETED and CANCELLED rows supports retention
sweeps. A partial unique index on non-null run_id makes one durable run link
belong to at most one occurrence. Revision
definitions use the same canonical JSON rules as versioned agent and policy
records. Database constraints enforce state/pause-reason consistency and the
occurrence disposition's nullable-field rules.
The two nullable occurrence-link foreign keys are initially deferred so the natural-key occurrence can be inserted before its dedicated session and run in the same materialization transaction. Commit still requires both linked rows, and the occurrence constraint rejects every non-materialized linked form.
Session erasure follows the existing deletion contract. In the same transaction
and before deleting the session graph, it atomically sets links_erased_at and
clears both identifiers on the linked materialized occurrence. The ON DELETE
SET NULL foreign keys remain a defensive backstop. The database accepts only
the live-linked or explicitly erased
forms, so a generic nullable link cannot be mistaken for a failed
materialization. The occurrence, schedule identity, nominal and materialized
times, disposition, non-secret authority version, erasure timestamp, and audit
events remain for the configured operational retention period; erased session
or run content does not. The links never cascade from a session into schedule
history.
The maintenance role periodically selects at most
scheduling.terminal_purge_batch schedules in COMPLETED or CANCELLED state
whose terminal updated_at is at least
scheduling.terminal_retention_days old. A transaction-scoped advisory lock
and FOR UPDATE SKIP LOCKED make the hourly sweep safe across a fleet. The
transaction deletes schedule idempotency rows, occurrences, revisions, and the
schedule in dependency order. It never deletes the linked sessions or runs;
content-free process events remain under the event-log retention contract.
Configuration and deployment¶
Scheduling is default-off through AGENT_SCHEDULE_API_ENABLED=0 and
AGENT_SCHEDULE_WORKER_ENABLED=0. Production release validation accepts only
0 or 1 and requires the two flags to change together. Enabling it requires:
- PostgreSQL storage;
- the schedule API and schedule worker feature flags;
- a durable principal-directory adapter;
- at least one async worker slot and one interactive reserved slot;
- finite tenant ceilings for timeout, per-run cost, active runs, rate, daily cost, and monthly cost;
- a positive scan batch, fallback poll interval, and admission backoff;
- positive terminal-retention days, purge interval, and purge batch. The shipped values are 30 days, 3,600 seconds, and 100 records.
The API, interactive worker, async worker, and schedule worker are separate
roles in production. Interactive and async workers claim disjoint configured
priority classes, preserving at least one slot for each workload. Multiple
schedule workers are safe. The schedule worker has database access and no API
bearer token, model-provider, tool, sandbox, or object-store credential because
it executes none of those capabilities. Release validation connects through
that role and verifies the exact table privileges needed to check the schema
head, seed the session-history projection and checkpoint, materialize the run,
and enqueue schedule notifications; it also rejects superuser and BYPASSRLS
authority. That role has no UPDATE on events, so it cannot take the row
lock that serializes session-history projection with People erasure. It
projects only the session its own transaction created, which erasure cannot
see before commit, so that projection reads the erasure markers without the
lock; projecting any other session still takes it and fails closed. An
integration test materializes a due run through a login holding exactly the
release allowlist. Create, update, and resume issue a fixed-channel PostgreSQL
notification after commit; the scheduler always keeps its bounded table-scan
fallback.
Tracked metrics¶
Track:
- due-to-materialized lag p50, p95, and p99;
- due schedules and scheduler scan duration;
- materialized, missed, overlap-skipped, authorization-failed, and configuration-failed occurrence counts;
- coalesced misfire counts and outage span;
- active scheduled runs by tenant;
- schedules auto-paused by failure limit;
- scheduled run duration, terminal outcome, cost, lease reclaim, and cancellation latency;
- interactive and async claim latency separately;
- daily and monthly scheduled cost against tenant ceilings.
Metrics contain tenant-safe identifiers or aggregates and never instructions or credentials.
Model-callable creation¶
Milestone 19 closes the missing edge between a conversational request and the existing scheduling control plane. Milestone 20 widens the same capability to the four recurring calendar kinds without adding another tool:
name schedule.create
kind capability
source builtin
target_kind in_process
side_effect external_write
risk high
idempotency conditionally_idempotent
required_scopes schedule.write
timeout_seconds 15
maximum_output_bytes 4096
allow_parallel false
output_trust internal_tool
The input schema is closed. title and instruction are always required, and
exactly one of the compatible one-time at field or a recurring cadence
object is required:
{
"title": "Throw the ball for Marzipan",
"instruction": "Remind me to throw the ball for Marzipan.",
"at": "2026-08-25T02:00:00+00:00"
}
{
"title": "Month-end review",
"instruction": "Review the month and summarize unfinished commitments.",
"cadence": {
"kind": "MONTHLY",
"local_time": "18:00:00",
"days_of_month": [],
"last_day": true,
"timezone": "America/Los_Angeles"
}
}
at is one timezone-aware ISO 8601 instant. A recurring cadence is exactly
one DAILY, WEEKLY, MONTHLY, or YEARLY payload from the closed domain
union above. Natural-language parsing is not part of the tool: the model uses
system.current_time and, when the date, local time, calendar selector, or
timezone is not known, conversation.ask_user before proposing a call.
Supplying both at and cadence, or neither, fails before schedule state.
The tool is registered and present in the default agent only when both
AGENT_SCHEDULE_API_ENABLED and AGENT_SCHEDULE_WORKER_ENABLED are true.
schedule becomes a build-time builtin domain. The ordinary pipeline checks
the exact schedule.write scope and the default policy requires approval for
its EXTERNAL_WRITE classification. The approval view contains the concrete
title, instruction, exact instant or complete recurring cadence, and the empty
requested-scope set.
Execution calls ScheduleService.create directly with
ToolExecutionContext.principal and ToolExecutionContext.idempotency_key.
It performs no internal HTTP request and sees no credential. The application
service remains the one validator and persistence path, including request-key
replay. A past or naive instant, invalid calendar selector, or cadence with no
future occurrence is an argument failure and leaves no schedule.
The definition pins the active agent version and its policy profile and
sets requested_scopes = frozenset() unless use_website=true supplies the
validated website dependency described above (ADR-0172), or place_call=true
(version 1.3.0, ADR-0213) requests exactly mcp.bland_call.use and
mcp.bland_read.use; that flag fails as schedule.calling_unavailable when
calling is not composed. Its step, model-call, and tool-call
limits are the minimum of the active agent's limits and the existing schedule
ceilings. Cost is the minimum of the active agent's finite cost or 5 and the
schedule cost ceiling; run timeout is the lesser of 300 seconds and its
ceiling; misfire grace is the lesser of 3,600 seconds and its ceiling. The
limits pin final-synthesis reserves of 2 steps, 2 model calls, and USD 1, each
kept only when strictly below its pinned total. max_consecutive_failures is
2, so the schedule tolerates one failed occurrence and pauses automatically on
the second consecutive failure (ADR-0109). No model argument can widen any of
these values.
The successful result contains schedule_id, state, next_fire_at, and
whether the application request replayed. After the occurrence's run is
accounted, the outbox emits schedule_run_finished, which can arrive after the
nominal instant by the duration of the scheduled run. ADR-0091 subsequently
authorizes the occurrence's pinned revision title and nominal firing time in
the alert; instructions and run results remain excluded. The accountant and
materializer pass the already loaded revision to notification production so
both terminal and skipped outcomes preserve the schedule identity at firing.
Model-callable lifecycle¶
Milestone 23 closes the remaining operational gap between a conversational
schedule and its lifecycle. Five builtin capabilities are registered beside
schedule.create:
name side_effect risk idempotency scope parallel
---------------- --------------- ---- ----------- --------------- --------
schedule.list none low read_only schedule.read true
schedule.update external_write high conditional schedule.write false
schedule.pause external_write high idempotent schedule.write false
schedule.resume external_write high idempotent schedule.write false
schedule.cancel external_delete high idempotent schedule.cancel false
All five use target_kind: in_process, output_trust: internal_tool, and a
15-second timeout. Discovery has a 524,288-byte output ceiling so the bounded
fifty-record page remains valid even when every record carries the largest
legal yearly selector; one mutation has a 4,096-byte ceiling. They register and
enter the default-agent roster only while both schedule deployment flags are
enabled.
schedule.list accepts an optional limit from 1 through 50, defaulting to
50, and an optional opaque cursor. It returns items and next_cursor.
Each item is a summary allow-list containing schedule_id, title, state,
pause_reason, current_revision, next_fire_at, and the canonical
cadence. It never returns the instruction, policy profile, requested scopes,
limits, principal identity, or persistence cursor internals. The model uses
these summaries to resolve a user's reference. If no record or more than one
record plausibly matches, it asks the user instead of selecting one by guess.
Each mutation has the same closed input:
The stable UUID identifies the schedule; the positive revision guards the
selection against concurrent changes. A title is never a mutation key. The
approval view names the action, exact identifier, and expected revision. The
ordinary pipeline checks the exact scope before approval, and the existing
default policy requires approval for every lifecycle mutation. Execution marks
the effect boundary and calls the matching ScheduleService method directly
with ToolExecutionContext.principal.
Successful mutation output contains the stable ID, current revision, state, pause reason, and next firing. Repeating the same valid transition returns the same legal state without another transition event. A malformed ID or revision, unknown schedule, stale revision, illegal terminal transition, or recurrence with no future instant returns a stable non-retryable argument/not-found failure and never changes another schedule.
Conversational “delete” is deliberately implemented by schedule.cancel.
Cancellation is terminal, preserves the schedule and occurrence ledger during
the configured retention window, stops future occurrences, and does not cancel
an occurrence's already materialized run. Resume preserves the existing
no-backfill rule: it chooses the first occurrence strictly after the current
instant.
schedule.update accepts the same stable identity fields plus a closed patch:
{
"schedule_id": "66af9c8d-d71c-4b36-bb06-cd44d4800bad",
"expected_revision": 3,
"instruction": "Summarize only security and infrastructure news.",
"cadence": {
"kind": "WEEKLY",
"local_time": "08:00:00",
"weekdays": [1, 3, 5],
"timezone": "America/Los_Angeles"
}
}
At least one of title, instruction, at, or cadence is required. at
and cadence are mutually exclusive and use the same closed values as
schedule.create; omitted patch fields retain their current values. The
approval view shows the exact ID, expected revision, and complete supplied
patch, identifying omitted fields as unchanged rather than inventing their
current content.
The tool passes its invocation idempotency key to a write-authorized
ScheduleService patch operation. In one application transaction, the service
checks the key and expected revision, loads the current immutable revision,
and constructs the complete next definition. It preserves agent_id,
agent_version, policy_profile, requested_scopes, browser_profile_id, limits,
run_timeout_seconds, misfire_grace_seconds, and
max_consecutive_failures; those fields are neither tool inputs nor tool
outputs. The ordinary definition validator, secret scanner, recurrence
calculator, immutable revision repository, and schedule.updated audit event
remain authoritative.
A valid ACTIVE edit creates revision N+1 and sets next_fire_at to the first
instant strictly after the update time. A valid PAUSED edit also creates N+1
but remains PAUSED with no next firing; later resume uses the new cadence. A
COMPLETED or CANCELLED schedule remains terminal. A patch whose normalized
definition is unchanged records the idempotency key but writes no revision or
transition event. Identical recovery returns the current schedule with
replayed: true; key reuse with different content, a stale revision, or
invalid content fails closed without another revision.
Build sequence¶
- Add the domain values, recurrence calculator, and deterministic civil-time property tests. M11.
- Add the four-table migration, ORM models, repositories, constraints, and contract tests. M11.
- Add the principal-directory and admission ports with development and PostgreSQL-backed adapters. M11.
- Add schedule lifecycle application services and boundary tests. M11.
- Add occurrence materialization in one unit-of-work transaction, beginning with the concurrent-worker and crash regressions. M11.
- Add the schedule worker, wakeup, bounded poll fallback, and async-capacity configuration. M11.
- Add the eight HTTP routes, exact scopes, idempotency, cursor pagination, and OpenAPI assertions. M11.
- Add post-run failure accounting, rolling cost admission, metrics, and operational dashboards. M11.
- Run the full non-live suite, PostgreSQL integration and resilience lanes, hosted CI, and the required GitHub CodeRabbit loop on one final head. M11.
- Add the closed
schedule.createschema, classification, approval view, exact-instant conversion, and application-service adapter. M19. - Register the tool only with both schedule flags, add it to the enabled agent roster on the same condition, and prove scope denial and retry idempotency through the ordinary pipeline. M19.
- Run the five Milestone 19 gates, the scheduling and notification partitions, the complete non-live suite, PostgreSQL integration, hosted CI, and the CodeRabbit loop on the final head. M19.
- Add monthly and yearly domain values, bounded calendar lookup and counting, and deterministic civil-time regression and property coverage. M20.
- Widen the HTTP definition union and the compatible
schedule.createschema, approval view, and application-service adapter for all four recurring kinds. M20. - Run the six Milestone 20 gates, every scheduling partition, the complete non-live suite, PostgreSQL integration, hosted CI, and the CodeRabbit loop on the final head. M20.
- Add the summary-only
schedule.listschema and the four revision-guarded lifecycle schemas, classifications, approval views, and direct application-service adapters. M23. - Register all five tools under the existing schedule flag pair, add them to the default agent on the same condition, and prove exact-scope denial, ambiguity-safe discovery, conflict behavior, approval, and retry safety through the ordinary pipeline. M23.
- Run the twelve Milestone 23 gates, every scheduling partition, the complete non-live suite, PostgreSQL integration, hosted CI, and the CodeRabbit loop on the final head. M23.
Hard gates¶
- A future occurrence never fires early. Generated one-time, daily, and
weekly definitions over many zones produce no occurrence whose
materialized_atprecedes its nominal UTC instant; a worker scan one microsecond beforenext_fire_atwrites nothing. Registered asgate.schedule.not_early, property. M11. - Concurrent schedulers materialize once. Two schedule workers race on one
due definition. Exactly one occurrence, session, run, seed checkpoint, and
creation-event sequence commit, and the other worker returns without a
second effect. Registered as
gate.schedule.materialize_once, case. M11. - Occurrence creation is atomic across a crash. Inject a crash after every
write in materialization. Before commit, no partial row survives and the
schedule remains due; after commit, retry returns the one complete linked
occurrence and run. Registered as
gate.schedule.materialize_atomic, case. M11. - Civil-time recurrence is deterministic. Property cases across the IANA
corpus, including both DST folds, assert earlier-instant selection for an
ambiguity, first-valid-instant selection for a gap, one firing per civil
occurrence, and reconstruction of
next_fire_at. Registered asgate.schedule.civil_time, property. M11. - Downtime catch-up is bounded. Advance the clock across thousands of
nominal instants. One scan creates at most one occurrence and one coalescing
event, advances directly to the first future instant, and never loops once
per missed date. Registered as
gate.schedule.misfire_bounded, case. M11. - Occurrences from one schedule never overlap. Hold one linked run
non-terminal across the next due instant. The next instant is recorded once
as
SKIPPED_OVERLAP, no second run exists, and a different schedule can still materialize. Registered asgate.schedule.no_overlap, case. M11. - Firing revalidates current authority. Create a schedule, then revoke a
requested scope, disable the principal, and change the authority version in
separate cases. Each due occurrence fails closed with an audited reason and
no session or run; restoring authority affects only a later occurrence.
Registered as
gate.schedule.authority_fresh, case. M11. - Scheduled execution uses exactly its requested scopes. A creator with a
superset of scopes schedules a restricted run. The run snapshots only the
requested subset, an undeclared tool is denied, and cross-tenant or
cross-principal schedule reads return not found. Registered as
gate.schedule.scope_isolated, case. M11. - A revision pins reproducible execution inputs. Update a schedule after an
occurrence, then rotate the agent's floating configuration. The first
occurrence still resolves its original agent version, policy profile,
instruction, scopes, cadence, and limits; the next uses only the new
revision. Registered as
gate.schedule.revision_pinned, case. M11. - Lifecycle races are linearized. Property tests interleave update,
pause, resume, cancel, and due scans with stale and current revisions. Every
history is equivalent to one allowed serial order, terminal cancellation
never reopens, and paused time never backfills. Registered as
gate.schedule.lifecycle_linear, property. M11. - Schedule cancellation and run cancellation are separate. Cancel a
schedule with an active occurrence. Future occurrences stop, the active run
remains unchanged, and only a separately authorized run-cancel request can
cancel it. Registered as
gate.schedule.cancel_separate, case. M11. - Every scheduled run is bounded. Validation rejects a missing or
non-positive timeout, step, model-call, tool-call, or cost bound. A valid
occurrence stamps all limits and its derived deadline onto the run, and the
ordinary runtime enforces them after crash recovery. Registered as
gate.schedule.run_bounded, case. M11. - Scheduled load cannot starve interactive work. Saturate async scheduled
capacity while submitting interactive runs. Reserved slots keep interactive
claim latency within its configured bound and preserve at least one async
slot so schedules also progress. Registered as
gate.schedule.priority_fair, case. M11. - A client can recover every result while offline. Fire successful,
failed, cancelled, missed, and overlap-skipped occurrences with no client
connected. Paginated occurrence reads later return each disposition and the
exact run link where one exists, with no dependence on transient
notifications. Registered as
gate.schedule.offline_results, case. M11. - No credential becomes schedule state. A structural walk finds no token,
secret, cookie, or credential field on schedule domain or persistence
models; a validation corpus of credential-shaped instructions is rejected
without storing or logging the matched value. Registered as
gate.schedule.no_credentials, corpus. M11. - Schedule values preserve their invariants. Generated schedule,
revision, and occurrence inputs either construct a complete legal value or
fail with a stable validation error; no partially valid state is admitted.
Registered as
gate.schedule.domain_invariants, property. M11. - Every scheduling port has executable adapter contracts. The definition,
occurrence, and request-idempotency protocols each have a named shared
contract exercised by every registered adapter. Registered as
gate.schedule.repository_contract, structural. M11. - The scheduling schema encodes its trust boundaries. Metadata inspection
proves primary and foreign keys, occurrence uniqueness, immutable revision
references, nullable-link rules, erasure evidence, query indexes, and row-
level security. Registered as
gate.schedule.persistence_schema, structural. M11. - Scheduling migrates cleanly from an empty database. The migration chain
reaches head and exactly matches the declared SQLAlchemy metadata.
Registered as
gate.schedule.migration_clean, case. M11. - The scheduling migration is reversible at its boundary. Upgrade,
downgrade, and re-upgrade from the immediate predecessor leave a valid
schema. Registered as
gate.schedule.migration_stepwise, case. M11. - Scheduling repositories share the application transaction. Definition,
revision, occurrence, and request-idempotency writes commit or roll back
together through the ordinary unit of work. Registered as
gate.schedule.uow_atomic, case. M11. - Session erasure preserves an explicit occurrence audit state. Deletion
atomically marks a materialized link erased and clears the nullable session
and run links before deleting their graph, without retaining erased content. Registered as
gate.schedule.erasure_audited, case. M11. - Scheduling persistence is principal isolated. PostgreSQL row-level
security and repository predicates prevent cross-tenant and cross-principal
reads and mutations. Registered as
gate.schedule.persistence_isolated, case. M11. - Conversational creation is a governed, default-off capability.
schedule.createregisters only when both schedule flags are enabled and is classified as approval-gated, conditionally idempotent, non-parallel, and exactly scoped byschedule.write. Registered asgate.schedule.model_create_contract, structural. M19. - A direct reminder request can create one schedule through the ordinary
tool pipeline. The call waits for approval, persists one future one-time
definition, pins the active agent, and delegates no tool scopes. Registered
as
gate.schedule.model_create_happy_path, case. M19. - Conversational creation cannot outrun its principal. A run without
schedule.writeis denied before execution and leaves no schedule state. Registered asgate.schedule.model_create_authorization, case. M19. - One-time conversational input accepts only an exact future instant. A
past or non-timezone-aware
atinstant returns a stable argument failure and leaves no schedule state. Registered asgate.schedule.model_create_validation, case. M19. - Conversational creation is retry-safe. Replaying one tool idempotency
key and identical normalized arguments returns the original schedule rather
than creating a duplicate. Registered as
gate.schedule.model_create_retry, case. M19. - Calendar cadence values are closed and canonical. Monthly selectors
require unique valid numbered days or explicit month-end, yearly selectors
require unique possible month/day pairs, and every accepted value
round-trips without semantic drift. Registered as
gate.schedule.calendar_values, property. M20. - Monthly and yearly recurrence is deterministic. Numbered monthly days
skip missing dates, last-day selects each actual month end, February 29
skips non-leap years, and both new kinds obey the existing IANA fold, gap,
and no-early rules. Registered as
gate.schedule.calendar_recurrence, property. M20. - Calendar downtime remains bounded. Next, latest, and inclusive count
operations cross long month and year spans without iterating once per
missed occurrence, and one materializer scan still records at most one
candidate plus one coalescing event. Registered as
gate.schedule.calendar_misfire_bounded, case. M20. - The HTTP control plane round-trips every calendar kind. Create and
update accept daily, weekly, monthly, and yearly definitions through the
existing route and immutable revision path, returning the canonical
cadence and exact next civil instant. Registered as
gate.schedule.calendar_http_roundtrip, case. M20. - Conversational recurring creation stays governed and least privilege.
Daily, weekly, monthly, and yearly calls use the one
schedule.createcapability, wait for ordinary approval, persist one definition, pin the active agent, and delegate no scopes. Registered asgate.schedule.model_create_recurring, case. M20. - Conversational calendar validation and replay fail closed. Both/neither
atandcadence, invalid calendar values, and an idempotency key reused with different recurrence content create no duplicate state, while an identical retry returns the original schedule. Registered asgate.schedule.model_create_recurring_validation, case. M20. - Conversational lifecycle tools are feature-gated and honestly
classified. The schedule flag pair registers and advertises exactly
schedule.list,schedule.update,schedule.pause,schedule.resume, andschedule.cancelbeside creation, with the declared effects, risks, idempotency, scopes, and parallelism. Registered asgate.schedule.model_lifecycle_contract, structural. M23. - Conversational discovery is bounded and summary-only. A
principal-scoped list returns no more than fifty canonical summaries with
stable IDs and revisions, paginates by opaque cursor, and exposes no
instruction or authority fields. Registered as
gate.schedule.model_lifecycle_discovery, case. M23. - Conversational pause and resume use the ordinary approval pipeline. A
named schedule is discovered, paused after one approval, then resumed
after one approval at its first strictly future occurrence without
backfill. Registered as
gate.schedule.model_pause_resume, case. M23. - Conversational delete is terminal schedule cancellation. An approved
schedule.cancelstops future occurrences, preserves the record and ledger, and leaves an already materialized run unchanged. Registered asgate.schedule.model_cancel, case. M23. - Every conversational lifecycle operation has exact authority. Listing
requires only
schedule.read, pause and resume requireschedule.write, and cancel requiresschedule.cancel; a missing scope is denied before execution or approval and leaves state unchanged. Registered asgate.schedule.model_lifecycle_authorization, case. M23. - Lifecycle selection and validation fail closed. Invalid identifiers or
revisions, unknown schedules, stale revisions, and illegal terminal
transitions return stable failures without mutating the selected or any
other schedule. Registered as
gate.schedule.model_lifecycle_validation, case. M23. - Conversational lifecycle retries are state-idempotent. Replaying an
identical authorized invocation cannot duplicate a transition event or
move a schedule beyond the requested legal state. Registered as
gate.schedule.model_lifecycle_retry, case. M23. - Conversational update is a closed, governed patch.
schedule.updateis feature-gated, approval-gated, conditionally idempotent, non-parallel, and exactly scoped byschedule.write; its input accepts only a stable ID, expected revision, and title, instruction, or one closed cadence replacement. Registered asgate.schedule.model_update_contract, structural. M23. - A conversational edit writes one future immutable revision. After
summary discovery and ordinary approval, changing content and cadence
leaves the prior revision unchanged, creates N+1, and recomputes an active
schedule's first strictly future firing. Registered as
gate.schedule.model_update_happy_path, case. M23. - Conversational update cannot widen hidden execution authority. A patch
preserves the current agent, policy, requested scopes, finite limits,
timeout, misfire grace, and failure policy; updating while paused keeps the
schedule paused with no next firing. Registered as
gate.schedule.model_update_preserves_authority, case. M23. - Conversational update authorization and validation fail closed. A
missing
schedule.writescope is denied before approval, and an empty or malformed patch, secret-like content, unknown or terminal schedule, or stale revision writes no revision or event. Registered asgate.schedule.model_update_validation, case. M23. - Conversational update recovery applies at most once. An identical
idempotency-key replay returns the schedule without another revision or
event, while the same key with changed content conflicts without an
effect. Registered as
gate.schedule.model_update_retry, case. M23.
Open questions¶
- Milestone 12 delivered Apple push for schedule outcomes. Email and webhook delivery still need their own destination authorization, retry, and secret-handling contracts.
- Arbitrary cron and RFC 5545 recurrence are intentionally outside the closed cadence union. Evaluation of real schedule demand should choose which one, if either, expands it.
- Interval multipliers such as every second week require an explicit anchor and revision-update phase contract. They remain outside the unanchored calendar values rather than acquiring an implicit creation-date phase.
- A future continuous-session mode would conflict with the one-active-run constraint and unbounded conversation growth. It requires separate evidence and is not an alternate interpretation of the dedicated-session rule here.