ADR-0018: Guest access to projects is granted via per-project permission sets, not project DSL
- Status: Accepted
- Date: 2026-05-25
- Deciders: @karasu
- Supersedes: —
- Superseded by: —
Context
ADR-0017 establishes guests as system-level identities with daemon-managed credentials. A guest exists once in the deployment; their user_id is guest:<ulid> regardless of how many projects they participate in.
The next question: how does a guest gain access to a project, and what can they do once in?
The relevant constraint set:
- One guest, many projects. A guest's grants accrue independently per project. Adding a grant to project A must not affect their grants on project B.
- Different guests, different access. Two guests with grants on the same project may have different exposed surfaces — one can invoke workflow X, the other can invoke workflows Y and Z.
- Grants are operator-local relationships, not portable project config. ADR-0011 made the DSL portable:
git clonepluskaged project loadis the full path to working on someone else's project. A guest's grant is not a property of the project — it's a property of this operator's relationship with this guest in the context of this project. Two different operators forking the same project DSL would have entirely different guest rosters. - Workflow visibility is per-grant, not per-project. The set of workflows a guest can invoke is part of the grant, not a property of the workflow itself. A workflow declared in the DSL is available to grant; whether it's actually exposed to a particular guest is a grant-level decision.
- Issues are an orthogonal capability. Whether a guest can file issues against a project is independent of which workflows they can invoke. The two should be separately grantable.
- No RBAC. Roles, role hierarchies, and inheritance trees are out of scope. The actual permissions space is small and finite; a flat enum or bitfield is sufficient.
- Revoke must be cheap. Operators change their mind. A grant should be removable in one click without DSL edits, redeploys, or restarts.
The question this ADR answers is where grants live (daemon state, not DSL) and what shape they take (a small enumerated permission_set, not a role system).
Decision
Per-project guest access is granted via rows in a daemon-owned
project_guest_grantstable. Each row binds one guest to one project with apermission_set: a small enumerated vocabulary describing which workflows are exposed and what side-channel capabilities (filing issues, viewing other guests' issues) are unlocked. Grants live in daemon state, not the project DSL. Workflows are defined in the DSL (portable); grants compose definitions with relationships (operator-local).
Concretely:
- Table:
project_guest_grants(project_id, user_id, permission_set, granted_at, granted_by, last_modified_at). Primary key(project_id, user_id). A guest has at most one grant per project; the grant is updated in place rather than versioned. permission_set: stored as a JSON blob with a Zod-validated shape (see below). Small and additive.- Operator-only mutation: all grant CRUD endpoints require operator auth (per ADR-0007). A guest cannot self-modify or self-revoke a grant.
- Revoke is delete:
DELETE /api/v1/projects/:id/guests/:user_id. Existing guest sessions are not killed (they expire naturally), but the next request that attempts a project-scoped action fails authorization. The guest's/gproject list refreshes to exclude this project on next load.
permission_set vocabulary (v1)
{
"workflows": ["testimonial.add", "blog.draft"],
"issues": {
"file": true,
"view_own": true,
"view_all": false,
"comment_own": true
},
"session": {
"view_own_history": true
}
}
| Field | Type | Meaning |
|---|---|---|
workflows |
string[] |
Names of workflows the guest can invoke. Must reference workflows declared in the project's DSL workflows: block (ADR-0019). Validated at grant-write time; stale entries (workflow renamed/removed) are tolerated at read time but rejected at invocation time. |
issues.file |
bool |
Guest can create new issues in this project. |
issues.view_own |
bool |
Guest can see issues they filed. |
issues.view_all |
bool |
Guest can see all issues in the project. Distinct from view_own; usually false for arms-length guests, true for collaborators. |
issues.comment_own |
bool |
Guest can add updates/comments to issues they filed. |
session.view_own_history |
bool |
Guest can see the message history of their own past chat sessions on this project. |
The vocabulary is additive — adding new permission fields in the future is backwards-compatible. Removing fields requires careful migration. Unknown fields in stored grants are tolerated (forward-compat for downgrade scenarios).
There is no "admin" permission. A guest cannot manage other guests. That stays operator-only.
Composition with the DSL
The workflows: field in permission_set references workflow names declared in project.yaml (or overridden in project.local.yaml per ADR-0015). The relationship is:
project.yaml → declares: workflows: { testimonial.add, blog.draft, ... } (portable)
project.local.yaml → may override or nullify per-workflow (operator-local, gitignored)
↓
resolved workflow set for this operator's load of this project
↓
project_guest_grants → references workflow names by string (daemon state)
If the DSL changes and a workflow named in a grant is removed:
- At read time (operator loads the grant management UI): stale workflow names are shown with a "no longer in DSL" indicator. The grant is not auto-cleaned.
- At invocation time (guest tries to invoke): the daemon rejects with
workflow_not_found. The guest sees a "this workflow is no longer available" error. - At project-load time: the daemon logs a warning per stale reference but does not refuse to load.
This tolerates DSL churn during development without destroying operator-set grants. Operators can clean up stale references via the UI.
UI integration
/projects/:id/settings gains a Guests section (operator-only):
- List of current grants: guest handle, granted_at, workflows count, issues flags.
- "Invite guest to project" — picker from the global guest list (per ADR-0017) + a permission_set editor.
- Edit / revoke per row.
A daemon-wide /config/guests (also operator-only) shows the system guest roster and which projects each is granted on. Distinct from /projects/:id/settings — that's project-centric, this is guest-centric.
Audit semantics
New events on top of ADR-0017's set:
grant.created— operator added a guest to a project.grant.modified— permission_set changed.grant.revoked— grant deleted.
Every event records both the operator (granted_by) and the affected guest (user_id).
Consequences
What this commits us to
- A
project_guest_grantstable in SQLite, scoped per project and per guest. - A Zod schema for the
permission_setshape, evolving additively over time. Initial schema inspecs/guests.md. - API endpoints under
/api/v1/projects/:id/guests/*(operator-only): list, create, update, delete. - A guest-management section in project settings UI (operator-only).
- A daemon-wide guest roster page (operator-only) showing cross-project view.
- Grant resolution at every guest-scoped API hit: the daemon looks up
(project_id, user_id)inproject_guest_grantsand checks the relevantpermission_setfield before authorizing. - Stale-workflow tolerance: grants survive DSL renames; invocation-time validation catches drift.
What this forecloses
- No grants in the DSL. A workflow named
testimonial.addcannot say "and this is exposed to guests X and Y." If it did, the project would carry the operator's roster — violates ADR-0011 portability. Operators who want such a thing fork the workflow into theirproject.local.yaml. - No role hierarchies. No "guest" / "collaborator" / "admin" tiers, no inheritance, no group memberships. Each grant is flat. If the use cases expand, we revisit; the additive
permission_setshape leaves room. - No guest-managed grants. A guest cannot invite other guests, even to their own issues. Operator is the only principal that can mint or modify grants.
- No time-limited grants in v1. A grant is until revoked. A
expires_atcolumn is plausibly added later; not in scope now. - No project-template "default permissions." Each grant is set explicitly. Operator can build their own habits; the daemon doesn't carry a template.
What becomes easier
- Adding a guest to a new project: a few clicks in project settings, no DSL edits, no commits, no restart.
- Revoking access: one click. No artifact in version control.
- Different access for different guests on the same project: just two grants with different
permission_setblobs. No DSL gymnastics. - Audit trail for who can do what, when, and who granted it.
- DSL stays focused on capabilities; grants stay focused on relationships. Two concerns, two surfaces.
What becomes harder
- There are now two places operators look to understand a project's exposed surface: the DSL (what workflows exist) and the daemon (who can invoke them). Documented in
specs/guests.mdand surfaced in the project settings UI to mitigate. - DSL changes can silently invalidate grants. The stale-reference tolerance avoids breakage but operators have to occasionally clean up. The UI flags stale references.
- Migration story for the
permission_setschema: additive fields are fine; semantic changes need care. We document a forward-compat strategy in the spec.
Alternatives considered
Alternative A — Grants in the DSL
Why tempting: Single source of truth. A git clone carries the whole project including its access policy. Reviewable in a PR.
Why rejected: Violates ADR-0011. A guest is an operator-local relationship; the DSL is the portable artifact. Two operators forking the same DSL should have entirely different guest rosters, not the original author's. The DSL is for capabilities, not relationships. Putting grants in the DSL forces operators to fork their project.yaml for every guest relationship change — which is precisely the friction ADR-0015 added project.local.yaml to avoid, but at a much smaller scale.
Alternative B — Single "guest" role with project-uniform permissions
Why tempting: One bit per guest per project: in or out. Trivially simple.
Why rejected: The same guest in the same project often needs different access to different workflows — "you can run the testimonial workflow but not the deploy workflow." A single bit can't express that. The minimum useful vocabulary is workflows-by-name; once you have that, the issues flags fall out naturally for very little extra cost.
Alternative C — Full RBAC with roles, role assignments, and permission lookups
Why tempting: Standard pattern. Lots of prior art. Extensible to elaborate scenarios.
Why rejected: The permission space is small. A flat permission_set per grant covers it. Adding role indirection (roles → permissions → guests) introduces a layer of state for no clear gain at this scale. If a kaged deployment ever has hundreds of guests across dozens of projects with elaborate access policies, that's the moment to revisit. We're not there.
Alternative D — Per-workflow grant rows
Why tempting: (project_id, user_id, workflow_name) → granted. Maximally normalized.
Why rejected: Workflows-as-list is the common case (a guest invokes 1–3 workflows per project); separate rows would explode the table and make the UI more painful (N rows per guest per project to render). The JSON permission_set is denormalised but reads as a single object — better for the actual usage pattern.
Alternative E — Defer to v1.x, ship guests without project scoping
Why tempting: Smaller v1. Get the auth tier landed first.
Why rejected: Guests without project scoping means every guest can hit every project. That's a vulnerability, not a feature. The grants table is the minimum useful scoping; deferring it would mean shipping a hole and patching later.
Open questions
- Default
permission_settemplate. When the operator creates a grant via UI, what should be pre-filled? Lean toward conservative (no workflows,issues.file: true,issues.view_own: true) so the operator has to consciously expose each workflow. - Grant for issues without project chat. If a guest's grant exposes no workflows but allows issue-filing, the project view is just an issue list — no chat. Probably fine. Worth UX-checking.
- Bulk grant operations. Inviting a guest to ten projects with the same permission_set in one operation is plausible. Not in v1; add later if needed.
- Per-grant notes. Operators may want to scribble "this is the photographer for the Smith wedding" against a grant. Not in
permission_set; a separatenotescolumn on the grant row is plausible. Lean yes for v1; one TEXT column, no big deal. - Cascade on guest deletion. When a guest is deleted (operator action), their grants cascade. When a project is unloaded, its grants persist (re-loading the same project restores them) but become un-actionable while the project is absent. Documented behavior.
References
- ADR-0005 — storage for the grants table
- ADR-0007 — operator auth that gates grant management
- ADR-0011 — portability principle that excludes grants from the DSL
- ADR-0015 — federated config the workflow definitions compose with
- ADR-0017 — the guest identities these grants reference
- ADR-0019 — the workflow definitions grants point at
docs/specs/project-dsl.md— DSL the grants referencedocs/specs/guests.md— implementation spec (to be written)- Original discussion: design conversation with colleagues, 2026-05-25