Per-User Scheduler Isolation
Isolate each gateway user's scheduled automations and suggestions with an optional principal owner key
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
- Create a job for a specific user
Set
principalon aScheduleJob.store.list(principal="alice")returns only Alice’s job, whilestore.list()returns everything.python from praisonaiagents.scheduler import ScheduleJob, Schedule from praisonaiagents.scheduler.config_store import ConfigYamlScheduleStore store = ConfigYamlScheduleStore() store.add(ScheduleJob( name="morning-brief", schedule=Schedule(kind="cron", cron_expr="0 8 * * *"), message="Send Alice her morning brief", principal="alice", )) alice_jobs = store.list(principal="alice") # → just Alice's job everything = store.list() # → every job, all owners - Scope a suggestion to a user
Set
principalon aSuggestion. Each owner sees only their own pending queue.python from praisonaiagents.scheduler.suggestion_store import SuggestionStore, Suggestion store = SuggestionStore() store.add(Suggestion( id="sug_abc", blueprint_name="morning-brief", slots={"hour": 8}, principal="alice", )) alice_pending = store.list_pending(principal="alice") # → Alice's suggestion bob_pending = store.list_pending(principal="bob") # → empty - Backward compatible (single-user)
Omit
principaleverywhere. Behaviour is unchanged — the store is one shared pool.python from praisonaiagents.scheduler import ScheduleJob, Schedule from praisonaiagents.scheduler.config_store import ConfigYamlScheduleStore store = ConfigYamlScheduleStore() store.add(ScheduleJob( name="daily-report", schedule=Schedule(kind="cron", cron_expr="0 9 * * *"), message="Send the daily report", )) store.list() # → every job in 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.
| Call | Scoped by principal | Cross-owner result |
|---|---|---|
list / list_pending | Yes | Other owners’ items are hidden |
get_by_name | Yes | Returns None (not found) |
remove_by_name | Yes | Returns False (not removed) |
accept / dismiss | Yes | Returns False (refused) |
get(job_id) / remove(job_id) | No | Direct-id access is capability-shaped |
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 toolsSuggestionEngine.propose/pending/accept/dismiss— the wrapper engine_automations.pyinline-keyboard callbacks andcreate_from_blueprint
Configuration Options
Every method below gained an optional principal filter. principal=None is the default and preserves global / single-tenant behaviour.
Suggestion field
| Field | Type | Default | Description |
|---|---|---|---|
principal | Optional[str] | None | Canonical identity of the owner. None = global / single-tenant. |
SuggestionStore methods
| Method | Signature | Behaviour |
|---|---|---|
add | add(self, suggestion: Suggestion) -> bool | Pending cap and dedup window are evaluated within the suggestion’s own principal. |
list_pending | list_pending(self, principal: Optional[str] = None) -> List[Suggestion] | Returns only that owner’s active suggestions; None = global. |
accept | accept(self, suggestion_id: str, principal: Optional[str] = None) -> bool | Returns False if the suggestion is owned by a different principal. |
dismiss | dismiss(self, suggestion_id: str, principal: Optional[str] = None) -> bool | Returns False if the suggestion is owned by a different principal. |
ScheduleJob field
| Field | Type | Default | Description |
|---|---|---|---|
principal | Optional[str] | None | Canonical identity of the end-user who owns this job. Distinct from agent_id (the owning agent) and origin (where it was created). Omitted from to_dict() when None. |
FileScheduleStore / ConfigYamlScheduleStore methods
| Method | Signature | Behaviour |
|---|---|---|
list | list(self, agent_id: Optional[str] = None, principal: Optional[str] = None) -> List[ScheduleJob] | Filters to jobs whose principal matches; None = global. |
get_by_name | get_by_name(self, name: str, principal: Optional[str] = None) -> Optional[ScheduleJob] | A job owned by a different identity is treated as not found (None). |
remove_by_name | remove_by_name(self, name: str, principal: Optional[str] = None) -> bool | A job owned by a different identity is skipped (returns False). |
On FileScheduleStore, add, update, remove, and remove_by_name raise OSError on persistence failure (full disk, EIO, permission denied) and leave in-memory state matching disk. Callers that mutate the store should expect this exception — see Store write durability.
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.
| Tool | Signature (only the new field) | Behaviour |
|---|---|---|
schedule_add | schedule_add(..., principal: str = "") | Trusted default (session identity wins). Stamps job.principal and scopes the duplicate-name check. |
schedule_list | schedule_list(principal: str = "") | Filters to jobs owned by the caller. |
schedule_remove | schedule_remove(name: str, principal: str = "") | Only removes a job owned by the caller. |
SuggestionEngine.propose | propose(..., principal: Optional[str] = None) | Stamps suggestion.principal. |
SuggestionEngine.pending | pending(principal: Optional[str] = None) | Filters. |
SuggestionEngine.accept | accept(suggestion_id, principal: Optional[str] = None) | Refuses cross-owner. |
SuggestionEngine.dismiss | dismiss(suggestion_id, principal: Optional[str] = None) | Refuses cross-owner. |
Semantics Reference
| Situation | Result |
|---|---|
principal=None on any reader / mutator | Global / single-tenant behaviour (unchanged). |
list_pending(principal="alice") / list(principal="alice") | Only items whose principal == "alice" are returned. |
accept(id, principal="bob") where suggestion is owned by "alice" | Returns False; suggestion is NOT accepted. |
dismiss(id, principal="bob") where suggestion is owned by "alice" | Returns False; suggestion is NOT dismissed. |
get_by_name("shared-name", principal="bob") where job is owned by "alice" | Returns None (treated as not found). |
remove_by_name("shared-name", principal="bob") where job is owned by "alice" | Returns False; job is NOT removed. |
add(Suggestion(principal="alice", ...)) while Alice is at MAX_PENDING_CAP | Returns False. |
add(Suggestion(principal="bob", ...)) while Alice is at MAX_PENDING_CAP | Returns True — Bob’s cap is measured independently. |
Two identical blueprint_name+slots from the same principal within 24h | Deduped (2nd rejected). |
Two identical blueprint_name+slots from different principals within 24h | Both accepted — dedup window is per-principal. |
Serialising a job with principal=None | principal key is absent from to_dict() output. |
Agent-tool call with SessionContext.unified_user_id set, no explicit principal= | Argument defaults to the session identity — job is stamped and reads/mutations are scoped to it. |
Agent-tool call with SessionContext.unified_user_id set AND explicit principal="other" | Session identity wins; the argument is ignored (prevents impersonation via prompt-injected tool calls). |
Agent-tool call with no SessionContext and explicit principal="carol" | Explicit value is honoured — trusted CLI / gateway-bridge path. |
Agent-tool call with no SessionContext and no explicit principal | None → global pool. Single-tenant / CLI behaviour, unchanged. |
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.
# Called from inside an agent turn on a bot gateway.
# No `principal=` needed — the tool reads SessionContext.unified_user_id.
from praisonaiagents.tools.schedule_tools import schedule_add, schedule_list
schedule_add("morning-brief", "daily", message="Send my morning brief")
schedule_list() # → only this user's jobsFor 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.
from praisonaiagents.scheduler import ScheduleJob, Schedule
from praisonaiagents.scheduler.config_store import ConfigYamlScheduleStore
store = ConfigYamlScheduleStore()
def create_for_caller(principal: str, name: str, cron: str, message: str) -> None:
store.add(ScheduleJob(
name=name,
schedule=Schedule(kind="cron", cron_expr=cron),
message=message,
principal=principal,
))
def list_for_caller(principal: str):
return store.list(principal=principal)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.
from praisonaiagents.scheduler import ScheduleJob, Schedule
job = ScheduleJob(
name="daily-report",
schedule=Schedule(kind="cron", cron_expr="0 9 * * *"),
message="Send the daily report",
)
assert "principal" not in job.to_dict() # key absent when NoneBest Practices
Reuse the gateway-resolved identity
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.
Keep the store shared, not sharded
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.
Match the mutation to the read
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.
Treat the direct id as a capability
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.