# ADR 0019: Resolve Actors to Principal Sets for Team Permissions

## Status

Accepted.

## Context

ADR-0004 deliberately chose a Feishu Docs-like permission model:
`grant(resource, principal, role)` plus separate resource settings. The first
Hub implementation stored `principal` as an opaque string and the Feishu trigger
used the sender's `open_id` directly. That was enough for individual teacher
grants, but not for production collaboration:

- a curriculum team needs one grant to apply to many teachers;
- Feishu departments, user groups, and project chats should be grantable
  principals;
- role-specific agent grants (`/review`, `/draft`) must compose with project
  permissions consistently;
- audit/debug output must explain which principal and grant allowed or denied a
  run.

## Decision

Introduce typed principals and resolve every actor to a principal set before
authorization.

### Principal types

`PrincipalType` is:

- `USER`: a Feishu user, identified by `feishuOpenId`.
- `TEAM`: a Hub-managed teacher team.
- `FEISHU_CHAT`: a Feishu chat/group.
- `FEISHU_DEPARTMENT`: a Feishu department.
- `FEISHU_USER_GROUP`: a Feishu user group.
- `APP`: an integration or bot principal.

`PermissionGrant` and `RoleTriggerGrant` store `principalType` and
`principalId`. Legacy opaque principal strings are backfilled as
`USER/<old principal>`.

### Team membership

Hub teams are first-class. A user can be a direct active member of a team. A
team can also be bound to external Feishu principals: chat, department, or user
group. If an actor resolves to any bound external principal, the actor also
resolves to that Hub team.

Membership changes are effective immediately because authorization resolves the
principal set at request time.

### External Feishu principal sync

Feishu department, user group, and chat memberships are not scattered through
callers. They are synchronized into `ExternalPrincipalMembership` rows and then
read by `PrincipalResolver`. The Feishu message context can also contribute the
current `FEISHU_CHAT` principal directly because receiving a group event is
already contextual proof that the actor is speaking in that group.

### Grant merging

Authorization evaluates all active grants matching any resolved principal for
the requested resource. There is no explicit deny in this ADR. Among matching
grants, the highest role wins:

```text
READ < EDIT < MANAGE
```

Action thresholds:

- project read requires `READ`.
- project edit requires `EDIT`.
- collaborator management requires `MANAGE`.
- agent trigger requires `EDIT`.
- normal agent cancel requires `MANAGE`.

`PermissionSettings` never grants access beyond grants. Settings only constrain
an already-granted capability. For `agentTrigger`:

- missing setting or `ROLE` means role-derived grants decide;
- `MANAGE_ONLY` raises the threshold to `MANAGE`;
- `DISABLED` denies the action.

Other settings keep their existing string storage, but the same rule applies:
settings are policy constraints, not positive grants.

### Role-trigger composition

Role-trigger grants are a second gate after the project-level `agent.trigger`
gate:

1. The actor must pass project `agent.trigger` for the project.
2. If no `RoleTriggerGrant` row has ever existed for `(projectId, roleId)`,
   the role is open for backward compatibility.
3. Once any row exists for `(projectId, roleId)`, including a revoked row, the
   role is configured. The actor must match an active role grant by any resolved
   principal.

This keeps `/review` restrictions orthogonal to ordinary edit access while
preventing a fully revoked role from silently reopening.

### Module seam

Callers use a single authorization module:

```ts
authorizer.can({
  actor,
  action,
  resource,
  roleId,
})
```

The caller does not know how users, teams, Feishu departments, user groups, or
chats expand. `PrincipalResolver` owns that implementation, and
`PermissionAuthorizer` owns grant/settings/role-trigger composition.

## Consequences

- Teacher teams become production-grade principals rather than UI sugar.
- Feishu external organization concepts can be synchronized and granted without
  leaking Feishu-specific checks into the trigger path.
- A single audit decision can report actor, principal set, matched grant, and
  matched role grant.
- Backward compatibility is explicit: old principal strings become user
  principals, and unconfigured role grants remain open.
