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.

Diagram

Multi-Tenant Scheduler

list(principal=’alice’)

list(principal=’bob’)

alice’s jobs only

bob’s jobs only

👤 alice

🗄️ SchedulerStore

👤 bob

✅ Alice’s jobs

✅ Bob’s jobs

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.

    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
  2. Scope a suggestion to a user

    Set principal on a Suggestion. 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
  3. Backward compatible (single-user)

    Omit principal everywhere. 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.

Diagram
SchedulerStoreIdentityResolverGatewayUserSchedulerStoreIdentityResolverGatewayUserowner != “alice” → returns False/automationsresolve()principal=”alice”list(principal=”alice”)Alice’s jobs onlyAlice’s jobsaccept a suggestionaccept(id, principal=”alice”)False (cross-owner refused)
CallScoped by principalCross-owner result
list / list_pendingYesOther owners’ items are hidden
get_by_nameYesReturns None (not found)
remove_by_nameYesReturns False (not removed)
accept / dismissYesReturns False (refused)
get(job_id) / remove(job_id)NoDirect-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 tools
  • SuggestionEngine.propose / pending / accept / dismiss — the wrapper engine
  • _automations.py inline-keyboard callbacks and create_from_blueprint
Diagram
SuggestionStoreSuggestionEngineSessionContextUserSuggestionStoreSuggestionEngineSessionContextUserunified_user_id=”tg:12345″taps /automationspending(principal=”tg:12345″)list_pending(principal=”tg:12345″)only this user’s suggestionstheir own queue

Configuration Options

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

Suggestion field

FieldTypeDefaultDescription
principalOptional[str]NoneCanonical identity of the owner. None = global / single-tenant.

SuggestionStore methods

MethodSignatureBehaviour
addadd(self, suggestion: Suggestion) -> boolPending cap and dedup window are evaluated within the suggestion’s own principal.
list_pendinglist_pending(self, principal: Optional[str] = None) -> List[Suggestion]Returns only that owner’s active suggestions; None = global.
acceptaccept(self, suggestion_id: str, principal: Optional[str] = None) -> boolReturns False if the suggestion is owned by a different principal.
dismissdismiss(self, suggestion_id: str, principal: Optional[str] = None) -> boolReturns False if the suggestion is owned by a different principal.

ScheduleJob field

FieldTypeDefaultDescription
principalOptional[str]NoneCanonical 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

MethodSignatureBehaviour
listlist(self, agent_id: Optional[str] = None, principal: Optional[str] = None) -> List[ScheduleJob]Filters to jobs whose principal matches; None = global.
get_by_nameget_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_nameremove_by_name(self, name: str, principal: Optional[str] = None) -> boolA 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.

ToolSignature (only the new field)Behaviour
schedule_addschedule_add(..., principal: str = "")Trusted default (session identity wins). Stamps job.principal and scopes the duplicate-name check.
schedule_listschedule_list(principal: str = "")Filters to jobs owned by the caller.
schedule_removeschedule_remove(name: str, principal: str = "")Only removes a job owned by the caller.
SuggestionEngine.proposepropose(..., principal: Optional[str] = None)Stamps suggestion.principal.
SuggestionEngine.pendingpending(principal: Optional[str] = None)Filters.
SuggestionEngine.acceptaccept(suggestion_id, principal: Optional[str] = None)Refuses cross-owner.
SuggestionEngine.dismissdismiss(suggestion_id, principal: Optional[str] = None)Refuses cross-owner.

Semantics Reference

SituationResult
principal=None on any reader / mutatorGlobal / 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_CAPReturns False.
add(Suggestion(principal="bob", ...)) while Alice is at MAX_PENDING_CAPReturns True — Bob’s cap is measured independently.
Two identical blueprint_name+slots from the same principal within 24hDeduped (2nd rejected).
Two identical blueprint_name+slots from different principals within 24hBoth accepted — dedup window is per-principal.
Serialising a job with principal=Noneprincipal 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 principalNone → global pool. Single-tenant / CLI behaviour, unchanged.

Choosing When to Scope

Diagram

Single-user CLI
or one DM

Multi-user gateway
shared bot

Shared team channel
everyone sees all

Who reaches
the scheduler?

Omit principal
global pool

Pass the resolved
identity as principal

Keep principal=None
group 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.

python
# 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 jobs

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.

python
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.

python
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 None

Best 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.


Related