Skip to main content
Pass a principal when creating a scheduled automation or a suggestion, and every list / accept / dismiss / remove call is automatically scoped to that owner — one gateway user’s jobs are invisible to another’s.

Quick Start

1

Create a job for a specific user

Set principal on a ScheduleJob. store.list(principal="alice") returns only Alice’s job, while store.list() returns everything.
2

Scope a suggestion to a user

Set principal on a Suggestion. Each owner sees only their own pending queue.
3

Backward compatible (single-user)

Omit principal everywhere. Behaviour is unchanged — the store is one shared pool.

How It Works

The gateway resolves the caller to a stable identity, then threads that string into every store call as principal. The store filters reads and refuses cross-owner mutations.

Auto-scoping from the session context

On a bot gateway (Telegram, Discord, Slack, or WhatsApp adapter) every turn installs a SessionContext carrying a unified_user_id. The agent tools and the gateway bridge read that value automatically, so isolation is on by default — you never thread identity by hand. These surfaces now default principal from SessionContext.unified_user_id:
  • schedule_add, schedule_list, schedule_remove — the agent-callable tools
  • SuggestionEngine.propose / pending / accept / dismiss — the wrapper engine
  • _automations.py inline-keyboard callbacks and create_from_blueprint
Session identity cannot be impersonated. On a multi-user gateway the authenticated per-turn SessionContext.unified_user_id is authoritative and always wins. A prompt-injected agent cannot pass principal=<victim> through an agent-callable tool to read or mutate another tenant’s jobs — the explicit argument is a trusted default, never an override of a resolved session identity.Resolution order:
  1. Session identity, when present (authenticated caller — cannot be overridden by tool arguments).
  2. Otherwise the explicit principal value (trusted CLI / gateway-bridge path where no session context is installed).
  3. Otherwise None ⇒ global, single-tenant behaviour (CLI / single-user deployments are unchanged).
On the CLI path no session is installed, so principal="carol" is honoured — that’s the trusted path, where there is no ambient identity to protect.

Configuration Options

Every method below gained an optional principal filter. principal=None is the default and preserves global / single-tenant behaviour.

Suggestion field

SuggestionStore methods

ScheduleJob field

ScheduleJob also carries provider / model / pin_model for pinning a job to a specific model — snapshot the model at creation so an unattended run fails closed on drift. See Scheduler Model Pin.

FileScheduleStore / ConfigYamlScheduleStore methods

Agent-tool signatures

The agent-callable tools and the wrapper engine gained an optional principal. On a gateway it defaults from the session; passing it by hand only matters on the CLI / bridge path.

Semantics Reference


Choosing When to Scope


Common Patterns

On a bot gateway the tools read the session for you — no principal= needed. Once the gateway installs a SessionContext, an agent turn gets per-user isolation for free.
For advanced callers (custom stores, non-gateway wiring) thread the resolved identity down yourself; the core carries the string and enforces the filter but never resolves identity itself.
Cross-owner reads are refused too. On the gateway bridge, accept_suggestion in _automations.py hides a suggestion whose principal doesn’t match the caller — the accept path returns “That suggestion was not found or has already been handled.” Details never leak by guessing an id.
get(job_id) and remove(job_id) are intentionally NOT scoped. Direct-id access is capability-shaped — the id is an opaque 12-character hex (uuid4().hex[:12]). The guessable surface is the human-readable name, so scoping the name-based path (get_by_name / remove_by_name) is what closes the leak.
Any store implementing ScheduleStoreProtocol MUST accept principal: Optional[str] = None on list, get_by_name, and remove_by_name for isinstance(store, ScheduleStoreProtocol) (runtime-checkable) to keep passing. It MUST also honour the “cross-owner is invisible” contract: hide other owners’ items on filtered reads, and refuse cross-owner mutations by returning None / False.

Backward Compatibility

Existing on-disk jobs.json, config.yaml, and suggestions.json files load unchanged. principal is omitted from to_dict() output when None, and every store method defaults principal to None — the pre-scoping global behaviour.

Best Practices

Thread the identity the gateway already resolved (IdentityResolverProtocol output) down as principal. Do not invent a new identity in the store layer — the core carries the string and enforces the filter, it does not resolve identity.
The store is designed for one file with per-record ownership, not one file per user. Pass principal on each record instead of creating a separate store per user.
If you list per-principal in the UI, always mutate per-principal in the callback. Never accept a naked sug_id from an untrusted UI without re-scoping to the caller’s principal.
get(id) and remove(id) are intentionally unscoped. Surface a job’s opaque id only to its owner — the id itself is the access token for direct-id operations.

Automation Suggestions

Per-user consent-first automation proposals.

Async Agent Scheduler

The scheduler that owns these jobs.

Scheduled Run Policy

Safety gate for unattended runs — a different layer.

Bot Command Access Control

Who may call /automations at all.