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
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.
Worker integration
When a job is claimed, it has a delegation_id. The worker must pass:
X-Delegation-Id: <delegation_id>
to any internal endpoint that writes user data (checkins, thoughts, clockify, projects).
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.