Delegations (hierarchical write authority)
Lucille uses hierarchical, revocable delegations to allow jobs to write on behalf of users without special scheduler roles. Delegations form a tree and revoke cascades through descendants.
Core rules
- Every delegation has an optional parent; roots have no parent.
- Children can only request a scope subset of the parent — for every named
capability, not just
can_write_user_data(a child cannot introduce a grant, e.g.mail.send, that the parent does not itself hold). - If a delegation is revoked, all descendants are revoked.
- Both issuer and recipient can revoke (revoke vs relinquish).
- Writes from worker jobs must include a delegation id.
- Scope is enforced at use time, not only when the delegation is minted: a read-only or narrowly-scoped delegation is rejected at any write gate whose required capability it does not grant.
Data model (user_delegations)
id: UUIDuser_id: UUIDissued_by_job_id: job that created this delegation (nullable for roots)issued_to_job_id: job that uses this delegationscope_json: JSON capability grants.can_write_user_data: trueis full write authority (a superset that satisfies any capability check). Narrower, per-tool grants can be listed alongside or instead of it, e.g.{ "can_write_user_data": false, "mail.send": true }— a delegation that may send mail but cannot otherwise write user data or use any other tool.created_at,expires_atrevoked_at,revoked_by_job_idparent_delegation_id,root_delegation_id
Lifetime (default TTL)
A root delegation gets a default expires_at of max(now, run_at) + TTL
(DELEGATION_DEFAULT_TTL_SECONDS, default 24h). The delegation_id is handed to
job-implementation subprocesses as a bearer token, so bounding its lifetime
limits the blast radius of a leaked id instead of it being valid forever.
Anchoring at the job's run_at keeps a deferred job's delegation valid until it
actually runs. A child can never outlive its parent: its expiry is clamped to
(or inherited from) the parent's. An explicit expires_at is respected (still
clamped for children); set the TTL to 0 to disable defaulting (never-expire,
the prior behavior). Existing rows are left untouched — only new delegations are
bounded.
Capability scoping (per-tool isolation)
require_active_delegation(session, delegation_id, user_id, capability="can_write_user_data")
is the single write gate. Beyond checking that the delegation is present, live,
unrevoked, and scoped to user_id, it verifies the delegation's scope_json
actually grants capability (app/crud/delegations.py:scope_grants). Call sites
that perform a specific external action pass the matching named capability (e.g.
capability="mail.send").
This makes capabilities non-transitive between nodes/jobs: the fact that one job/node can perform an action (send mail, write Clockify) does not grant the same ability to another. Each must hold a delegation that explicitly grants that capability. To hand a downstream job a single narrow ability, mint a child delegation whose scope lists only that capability — the chain-subset rule prevents it from being widened later.
Cascading revocation
Revocation uses a recursive CTE to set revoked_at for the target delegation and
all descendants. Any write attempt under a revoked or expired delegation fails with 403.
Central enforcement at MCP dispatch
Worker jobs run as MCP tool calls, all routed through mcp_server.dispatch_tool
(both the SSE call_tool and the HTTP /internal/mcp/call_tool paths). Rather
than relying on each handler to remember to validate, the gate is enforced once
at dispatch for every write tool: before a handler runs, dispatch_tool calls
require_active_delegation for any tool whose instinct metadata declares non-empty
resources (a write op — the same signal the worker uses to require a
delegation_id). Read-only kinds (e.g. *.recommend) declare empty resources
and are not gated. user_id from the call binds cross-user access; when a kind
carries only a resource id, the gate still enforces revocation/expiry/scope
against the delegation's own user. This makes coverage uniform and
regression-proof: a new write kind is gated the moment it declares a write
resource, with no per-handler wiring.
Worker integration
When a job is claimed, it has a delegation_id. The worker must pass:
X-Delegation-Id: <delegation_id>to any internal HTTP endpoint that writes user data (checkins, thoughts, clockify, projects), anddelegation_idin the arguments of any write MCP tool call (enforced centrally at dispatch, see above).
No special scheduler
Scheduler-created jobs receive a root delegation just like user-created jobs. Orchestrator jobs can create child delegations via internal endpoints and enqueue child jobs without permanent privileges.