syntax = "proto3";

package workspace.v1;

option go_package = "github.com/elloloop/workspace/gen/go/workspace/v1;workspacev1";

import "google/protobuf/struct.proto";
import "google/protobuf/timestamp.proto";

// ═══════════════════════════════════════════════════════════════════════
// Workspaces — the authorization layer
// ═══════════════════════════════════════════════════════════════════════
//
// This is an INTERNAL service. It is called service-to-service by trusted
// product backends — never directly by a browser or mobile client. Like
// Zanzibar, the caller authenticates as a SERVICE (a shared credential or
// mTLS at the mesh), NOT as an end user, and the user identity is passed as
// DATA, not inferred from the caller:
//
//   * `acting_user_id` on the management RPCs is the user the action is
//     performed on behalf of; the service authorizes that user (e.g. "must
//     be an admin to add a member").
//   * `subject_user_id` on Check is the user whose access is being tested.
//   * `project_id` is the configuration/model shard; empty means the
//     deployment's default project.
//   * `tenant_id` is the data-isolation shard WITHIN a project (identity
//     Project→Tenant). Empty means the project's default tenant. Every row
//     and relation tuple is scoped to (project_id, tenant_id).
//
// End-user authentication happens at the product's edge, before this
// service is in the picture. The product verifies the user's identity token
// (issued by github.com/elloloop/identity), then calls here as itself.
//
// Three services live here:
//
//   * WorkspaceService + GroupService — the product surface. Every user
//     owns a default PERSONAL workspace; they may create TEAM workspaces and
//     add other people (family or colleagues) with a role. Groups are a
//     reusable, nestable membership set, separate from workspaces. This
//     serves both B2C (a personal-assistant user sharing tasks) and B2B SaaS
//     (a company's employees collaborating), the way Claude and ChatGPT
//     model personal vs. team plans.
//
//   * AuthzService — a Zanzibar-style relation-tuple engine, generic over
//     namespaces. The product writes tuples and asks Check on its hot path.
//
//   * AdminService — project configuration. Each project carries its own
//     authorization model, so distinct products (e.g. a kids platform and a
//     professionals platform) run different models on one deployment. The
//     admin RPCs are gated by a platform-operator secret.

// ─── Common enums ──────────────────────────────────────────────────────

enum WorkspaceType {
  WORKSPACE_TYPE_UNSPECIFIED = 0;
  // PERSONAL is the single auto-provisioned workspace every user owns. It
  // cannot be deleted and admits exactly one member (the owner).
  WORKSPACE_TYPE_PERSONAL = 1;
  // TEAM is any workspace a user creates to collaborate with others —
  // a family, a project group, or a whole company.
  WORKSPACE_TYPE_TEAM = 2;
}

// Role is the coarse membership grade. It maps 1:1 onto a relation in the
// `workspace` authz namespace, ordered owner ⊃ admin ⊃ member ⊃ guest.
enum Role {
  ROLE_UNSPECIFIED = 0;
  ROLE_OWNER = 1;
  ROLE_ADMIN = 2;
  ROLE_MEMBER = 3;
  ROLE_GUEST = 4;
}

enum MembershipStatus {
  MEMBERSHIP_STATUS_UNSPECIFIED = 0;
  MEMBERSHIP_STATUS_ACTIVE = 1;
  MEMBERSHIP_STATUS_INVITED = 2;
  MEMBERSHIP_STATUS_SUSPENDED = 3;
}

enum InvitationStatus {
  INVITATION_STATUS_UNSPECIFIED = 0;
  INVITATION_STATUS_PENDING = 1;
  INVITATION_STATUS_ACCEPTED = 2;
  INVITATION_STATUS_REVOKED = 3;
  INVITATION_STATUS_EXPIRED = 4;
}

// ─── Product messages ──────────────────────────────────────────────────

message Workspace {
  string id = 1;
  // project_id is the configuration/model shard (identity ADR-0002).
  string project_id = 2;
  string slug = 3;
  string display_name = 4;
  WorkspaceType type = 5;
  string owner_user_id = 6;
  google.protobuf.Timestamp created_at = 7;
  google.protobuf.Timestamp updated_at = 8;
  // tenant_id is the data-isolation shard within the project; empty = default.
  string tenant_id = 9;
}

message Membership {
  string workspace_id = 1;
  string user_id = 2;
  Role role = 3;
  MembershipStatus status = 4;
  google.protobuf.Timestamp created_at = 5;
  google.protobuf.Timestamp updated_at = 6;
  string tenant_id = 7;
}

message Invitation {
  string id = 1;
  string workspace_id = 2;
  string email = 3;
  Role role = 4;
  InvitationStatus status = 5;
  string invited_by_user_id = 6;
  google.protobuf.Timestamp created_at = 7;
  google.protobuf.Timestamp expires_at = 8;
  // token is returned ONLY from CreateInvitation so the caller can deliver
  // it out of band; it is never echoed by ListInvitations.
  string token = 9;
  string tenant_id = 10;
}

// ─── WorkspaceService requests/responses ───────────────────────────────
//
// Every management request carries acting_user_id (the user the call acts
// as — used for authorization), project_id (the shard; empty = default), and
// tenant_id (the data-isolation shard within the project; empty = default).

message CreateWorkspaceRequest {
  string display_name = 1;
  // slug is optional; the server derives one from display_name when empty.
  string slug = 2;
  string acting_user_id = 3;
  string project_id = 4;
  string tenant_id = 5;
}
message CreateWorkspaceResponse { Workspace workspace = 1; }

message GetWorkspaceRequest {
  string workspace_id = 1;
  string acting_user_id = 2;
  string project_id = 3;
  string tenant_id = 4;
}
message GetWorkspaceResponse { Workspace workspace = 1; }

// ListWorkspaces returns every workspace acting_user_id is an active member
// of, auto-provisioning their PERSONAL workspace on first call.
message ListWorkspacesRequest {
  string acting_user_id = 1;
  string project_id = 2;
  string tenant_id = 3;
}
message ListWorkspacesResponse { repeated Workspace workspaces = 1; }

message UpdateWorkspaceRequest {
  string workspace_id = 1;
  string display_name = 2;
  string acting_user_id = 3;
  string project_id = 4;
  string tenant_id = 5;
}
message UpdateWorkspaceResponse { Workspace workspace = 1; }

// TransferOwnership hands a TEAM workspace's ownership to another user. Only
// the current owner (acting_user_id) may transfer; the new owner is granted the
// `owner` role (added as a member if needed) and the former owner is demoted to
// `admin` (kept, not orphaned). Personal workspaces cannot be transferred.
message TransferOwnershipRequest {
  string workspace_id = 1;
  string new_owner_user_id = 2;
  string acting_user_id = 3;
  string project_id = 4;
  string tenant_id = 5;
}
message TransferOwnershipResponse { Workspace workspace = 1; }

message DeleteWorkspaceRequest {
  string workspace_id = 1;
  string acting_user_id = 2;
  string project_id = 3;
  string tenant_id = 4;
}
message DeleteWorkspaceResponse {}

message AddMemberRequest {
  string workspace_id = 1;
  string user_id = 2;
  Role role = 3;
  string acting_user_id = 4;
  string project_id = 5;
  string tenant_id = 6;
}
message AddMemberResponse { Membership membership = 1; }

message UpdateMemberRoleRequest {
  string workspace_id = 1;
  string user_id = 2;
  Role role = 3;
  string acting_user_id = 4;
  string project_id = 5;
  string tenant_id = 6;
}
message UpdateMemberRoleResponse { Membership membership = 1; }

message RemoveMemberRequest {
  string workspace_id = 1;
  string user_id = 2;
  string acting_user_id = 3;
  string project_id = 4;
  string tenant_id = 5;
}
message RemoveMemberResponse {}

// SuspendMember revokes a member's live access WITHOUT deleting their
// membership: it removes the backing role tuple (so every Check denies) and
// marks the membership suspended. ReinstateMember restores both. This is the
// safe way to pause access — denial is by tuple absence, not a status read on
// the hot path.
message SuspendMemberRequest {
  string workspace_id = 1;
  string user_id = 2;
  string acting_user_id = 3;
  string project_id = 4;
  string tenant_id = 5;
}
message SuspendMemberResponse { Membership membership = 1; }

message ReinstateMemberRequest {
  string workspace_id = 1;
  string user_id = 2;
  string acting_user_id = 3;
  string project_id = 4;
  string tenant_id = 5;
}
message ReinstateMemberResponse { Membership membership = 1; }

message ListMembersRequest {
  string workspace_id = 1;
  string acting_user_id = 2;
  string project_id = 3;
  string tenant_id = 4;
}
message ListMembersResponse { repeated Membership members = 1; }

message CreateInvitationRequest {
  string workspace_id = 1;
  string email = 2;
  Role role = 3;
  string acting_user_id = 4;
  string project_id = 5;
  string tenant_id = 6;
}
message CreateInvitationResponse { Invitation invitation = 1; }

message AcceptInvitationRequest {
  string token = 1;
  string acting_user_id = 2;
  string project_id = 3;
  string tenant_id = 4;
}
message AcceptInvitationResponse { Membership membership = 1; }

message ListInvitationsRequest {
  string workspace_id = 1;
  string acting_user_id = 2;
  string project_id = 3;
  string tenant_id = 4;
}
message ListInvitationsResponse { repeated Invitation invitations = 1; }

message RevokeInvitationRequest {
  string invitation_id = 1;
  string acting_user_id = 2;
  string project_id = 3;
  string tenant_id = 4;
}
message RevokeInvitationResponse {}

service WorkspaceService {
  rpc CreateWorkspace(CreateWorkspaceRequest) returns (CreateWorkspaceResponse);
  rpc GetWorkspace(GetWorkspaceRequest) returns (GetWorkspaceResponse);
  rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse);
  rpc UpdateWorkspace(UpdateWorkspaceRequest) returns (UpdateWorkspaceResponse);
  rpc TransferOwnership(TransferOwnershipRequest) returns (TransferOwnershipResponse);
  rpc DeleteWorkspace(DeleteWorkspaceRequest) returns (DeleteWorkspaceResponse);

  rpc AddMember(AddMemberRequest) returns (AddMemberResponse);
  rpc UpdateMemberRole(UpdateMemberRoleRequest) returns (UpdateMemberRoleResponse);
  rpc RemoveMember(RemoveMemberRequest) returns (RemoveMemberResponse);
  rpc SuspendMember(SuspendMemberRequest) returns (SuspendMemberResponse);
  rpc ReinstateMember(ReinstateMemberRequest) returns (ReinstateMemberResponse);
  rpc ListMembers(ListMembersRequest) returns (ListMembersResponse);

  rpc CreateInvitation(CreateInvitationRequest) returns (CreateInvitationResponse);
  rpc AcceptInvitation(AcceptInvitationRequest) returns (AcceptInvitationResponse);
  rpc ListInvitations(ListInvitationsRequest) returns (ListInvitationsResponse);
  rpc RevokeInvitation(RevokeInvitationRequest) returns (RevokeInvitationResponse);
}

// ═══════════════════════════════════════════════════════════════════════
// GroupService — groups, separate from workspaces
// ═══════════════════════════════════════════════════════════════════════
//
// A group is a reusable, nestable set of subjects — an email distribution
// list, a chat group, an on-call rotation. It is deliberately NOT a
// workspace: a workspace is a tenancy/billing boundary with one owner,
// whereas a group is a membership set that can be referenced from many
// workspaces and resources. Groups nest (a group can be a member of another
// group), so "all-engineering" can contain "backend" and "frontend".
//
// Groups map to the `group` authz namespace, relation `member`. Granting a
// group access to anything is just a tuple whose subject is the userset
// `group:<id>#member` — e.g. workspace:acme#member@group:all-eng#member, or
// resource:doc1#viewer@group:family#member.

message Group {
  string id = 1;
  string project_id = 2;
  // workspace_id optionally scopes the group to a workspace (a company's
  // internal groups). Empty for project-level / standalone groups (a B2C
  // user's "family" list).
  string workspace_id = 3;
  string slug = 4;
  string display_name = 5;
  string created_by_user_id = 6;
  google.protobuf.Timestamp created_at = 7;
  google.protobuf.Timestamp updated_at = 8;
  string tenant_id = 9;
}

// GroupMember is either a user or a nested group.
message GroupMember {
  oneof member {
    string user_id = 1;
    string group_id = 2;
  }
}

message CreateGroupRequest {
  string display_name = 1;
  string slug = 2;
  // optional owning workspace; empty for a standalone group.
  string workspace_id = 3;
  string acting_user_id = 4;
  string project_id = 5;
  string tenant_id = 6;
}
message CreateGroupResponse { Group group = 1; }

message GetGroupRequest {
  string group_id = 1;
  string acting_user_id = 2;
  string project_id = 3;
  string tenant_id = 4;
}
message GetGroupResponse { Group group = 1; }

message ListGroupsRequest {
  // optional: restrict to groups owned by this workspace.
  string workspace_id = 1;
  string acting_user_id = 2;
  string project_id = 3;
  string tenant_id = 4;
}
message ListGroupsResponse { repeated Group groups = 1; }

message DeleteGroupRequest {
  string group_id = 1;
  string acting_user_id = 2;
  string project_id = 3;
  string tenant_id = 4;
}
message DeleteGroupResponse {}

message AddGroupMemberRequest {
  string group_id = 1;
  GroupMember member = 2;
  string acting_user_id = 3;
  string project_id = 4;
  string tenant_id = 5;
}
message AddGroupMemberResponse {}

message RemoveGroupMemberRequest {
  string group_id = 1;
  GroupMember member = 2;
  string acting_user_id = 3;
  string project_id = 4;
  string tenant_id = 5;
}
message RemoveGroupMemberResponse {}

message ListGroupMembersRequest {
  string group_id = 1;
  string acting_user_id = 2;
  string project_id = 3;
  string tenant_id = 4;
}
message ListGroupMembersResponse { repeated GroupMember members = 1; }

// ─── Enrollment lifecycle overlay ──────────────────────────────────────
//
// An enrollment overlays a lifecycle STATE on a group membership, so a group
// used as a cohort/class can track each member's progress. Only the
// access-bearing states (ENROLLED, ACTIVE) place the member in the group's
// `member` userset (the backing `group:<id>#member` tuple is present); the
// others (WAITLISTED, COMPLETED, DROPPED) record the state WITHOUT granting
// access (no tuple). Access is therefore revoked/granted purely by tuple
// presence — the same mechanism as workspace member suspend/reinstate — so
// Check/CheckSet over `group:<cohort>#member` naturally exclude a completed,
// dropped, or waitlisted enrollee. The enrollment row and its tuple move
// atomically. The overlay is additive: AddGroupMember/RemoveGroupMember still
// work for plain (un-tracked) membership.
enum EnrollmentState {
  ENROLLMENT_STATE_UNSPECIFIED = 0;
  // WAITLISTED: applied but not admitted — no access.
  ENROLLMENT_STATE_WAITLISTED = 1;
  // ENROLLED: admitted/registered — access (in the `member` set).
  ENROLLMENT_STATE_ENROLLED = 2;
  // ACTIVE: actively participating — access (in the `member` set).
  ENROLLMENT_STATE_ACTIVE = 3;
  // COMPLETED: finished — access ends.
  ENROLLMENT_STATE_COMPLETED = 4;
  // DROPPED: withdrew/removed — access ends.
  ENROLLMENT_STATE_DROPPED = 5;
}

message Enrollment {
  string group_id = 1;
  GroupMember member = 2;
  EnrollmentState state = 3;
  google.protobuf.Timestamp created_at = 4;
  google.protobuf.Timestamp updated_at = 5;
}

message SetEnrollmentStateRequest {
  string group_id = 1;
  GroupMember member = 2;
  EnrollmentState state = 3;
  string acting_user_id = 4;
  string project_id = 5;
  string tenant_id = 6;
}
message SetEnrollmentStateResponse { Enrollment enrollment = 1; }

message ListEnrollmentsRequest {
  string group_id = 1;
  string acting_user_id = 2;
  string project_id = 3;
  string tenant_id = 4;
}
message ListEnrollmentsResponse { repeated Enrollment enrollments = 1; }

service GroupService {
  rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
  rpc GetGroup(GetGroupRequest) returns (GetGroupResponse);
  rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
  rpc DeleteGroup(DeleteGroupRequest) returns (DeleteGroupResponse);
  rpc AddGroupMember(AddGroupMemberRequest) returns (AddGroupMemberResponse);
  rpc RemoveGroupMember(RemoveGroupMemberRequest) returns (RemoveGroupMemberResponse);
  rpc ListGroupMembers(ListGroupMembersRequest) returns (ListGroupMembersResponse);
  // SetEnrollmentState upserts a member's enrollment state and moves the
  // backing `member` tuple accordingly (present iff the state grants access).
  rpc SetEnrollmentState(SetEnrollmentStateRequest) returns (SetEnrollmentStateResponse);
  rpc ListEnrollments(ListEnrollmentsRequest) returns (ListEnrollmentsResponse);
}

// ═══════════════════════════════════════════════════════════════════════
// AuthzService — Zanzibar-style relation tuples
// ═══════════════════════════════════════════════════════════════════════
//
// A relation tuple reads `namespace:object_id#relation@subject`, where the
// subject is a concrete user, a userset (another object#relation), or the
// public wildcard (every user). These RPCs are pure data operations — the
// user is an argument, never the caller. (project_id, tenant_id) scope the
// operation; empty means the default project / default tenant.

// SubjectSet references the set of subjects related to another object — the
// userset `namespace:object_id#relation`. When relation is empty it denotes
// the object itself (rarely needed).
message SubjectSet {
  string namespace = 1;
  string object_id = 2;
  string relation = 3;
}

// Subject is the right-hand side of a tuple: a concrete user, a userset, or
// the public wildcard (matches every user — link-sharing / published
// content).
message Subject {
  oneof kind {
    string user_id = 1;
    SubjectSet set = 2;
    bool wildcard = 3;
  }
}

message RelationTuple {
  string project_id = 1;
  string namespace = 2;
  string object_id = 3;
  string relation = 4;
  Subject subject = 5;
  string tenant_id = 6;
  // expires_at, when set, makes the grant time-bounded: the tuple stops
  // granting access once this instant passes (enforced at read time, so Check
  // and tuple-to-userset walks both honor it). Unset = never expires. Expiry
  // is mutable metadata, NOT part of tuple identity: re-writing the same tuple
  // with a new expires_at updates it; deletes still match by identity.
  google.protobuf.Timestamp expires_at = 7;
  // condition_name, when set, makes the grant CONDITIONAL: it applies only if
  // the named built-in condition (e.g. consent_granted, age_at_least,
  // ip_in_cidrs, not_after) evaluates true against condition_params (bound
  // here) and the request-time CheckRequest.context. Unset = unconditional.
  // The condition is grant metadata, not part of tuple identity: re-writing the
  // tuple replaces it; deletes still match by identity. Unknown name / missing
  // input / ill-typed value all DENY (fail closed).
  string condition_name = 8;
  // condition_params are the static parameters bound to the condition at write
  // time (e.g. {"min_age": 13} for age_at_least).
  google.protobuf.Struct condition_params = 9;
}

message TupleUpdate {
  enum Op {
    OP_UNSPECIFIED = 0;
    OP_INSERT = 1;
    OP_DELETE = 2;
  }
  Op op = 1;
  RelationTuple tuple = 2;
}

message WriteRelationTuplesRequest {
  repeated TupleUpdate updates = 1;
  // project_id scopes every tuple in the batch; empty = default project.
  string project_id = 2;
  string tenant_id = 3;
}
message WriteRelationTuplesResponse {
  // consistency_token is an opaque "zookie" naming the shard's write sequence
  // reached by this batch. Pass it to a later Check/Expand/ListObjects/
  // BatchCheck/ReadRelationTuples as at_least_consistency_token to demand
  // read-after-write (observe at least this write). Optional to use.
  string consistency_token = 1;
}

// ReadRelationTuples returns stored tuples matching the non-empty filter
// fields (exact match). It does NOT evaluate userset rewrites — it is the
// raw store read. Use Check for permission decisions.
message ReadRelationTuplesRequest {
  string namespace = 1;
  string object_id = 2;
  string relation = 3;
  // optional: restrict to tuples whose subject is this concrete user.
  string subject_user_id = 4;
  string project_id = 5;
  string tenant_id = 6;
  // at_least_consistency_token, when set, demands the read reflect state at
  // least as fresh as the token (from a prior WriteRelationTuples). Empty =
  // read latest. A malformed or foreign-shard token is rejected.
  string at_least_consistency_token = 7;
}
message ReadRelationTuplesResponse { repeated RelationTuple tuples = 1; }

// Check answers "does subject have relation on namespace:object_id?",
// evaluating the namespace's userset rewrite rules transitively.
message CheckRequest {
  string namespace = 1;
  string object_id = 2;
  string relation = 3;
  string subject_user_id = 4;
  string project_id = 5;
  string tenant_id = 6;
  // subject_set asks whether a USERSET (e.g. group:cohort-7#member, or a
  // service-account set) has the relation, rather than a concrete user. It is
  // used iff subject_user_id is empty; exactly one of the two must be set. The
  // answer is "does the queried userset intersect the relation's effective
  // userset" — true if the set is structurally included, or if any concrete
  // member of the set has the relation.
  SubjectSet subject_set = 7;
  // context carries request-time attributes that CONDITIONAL grants are
  // evaluated against (e.g. {"age": 9, "consent": true, "ip": "1.2.3.4",
  // "now": "2026-06-16T00:00:00Z"}). Unset = no context; conditional grants
  // then fail closed, while unconditional grants are unaffected.
  google.protobuf.Struct context = 8;
  // at_least_consistency_token, when set, demands the check observe state at
  // least as fresh as the token (from a prior WriteRelationTuples). Empty =
  // read latest. A malformed or foreign-shard token is rejected.
  string at_least_consistency_token = 9;
}
message CheckResponse { bool allowed = 1; }

// BatchCheck answers many Check questions in one round-trip — the list/grid hot
// path (filter N documents/lessons to those a user can see). Results are
// index-aligned to items; a per-item validation error is reported in that
// item's result rather than failing the whole batch. Every item shares the
// request's project_id and tenant_id.
//
// NOTE: BatchCheck carries NO request context, so CONDITIONAL grants (caveats:
// scope_in / not_after / ip_in_cidrs / consent_granted / age_at_least) cannot be
// evaluated through it and a conditioned grant is denied. Use Check (which takes
// `context`) for conditional grants.
message BatchCheckItem {
  string namespace = 1;
  string object_id = 2;
  string relation = 3;
  string subject_user_id = 4;
}
message BatchCheckRequest {
  string project_id = 1;
  string tenant_id = 2;
  repeated BatchCheckItem items = 3;
  // at_least_consistency_token, when set, demands the batch observe state at
  // least as fresh as the token (from a prior WriteRelationTuples). Empty =
  // read latest. A malformed or foreign-shard token is rejected.
  string at_least_consistency_token = 4;
}
message BatchCheckResult {
  bool allowed = 1;
  // error is non-empty when the item could not be evaluated (e.g. invalid
  // arguments); allowed is then false.
  string error = 2;
}
message BatchCheckResponse { repeated BatchCheckResult results = 1; }

// Expand returns the effective userset tree for namespace:object_id#relation.
message ExpandRequest {
  string namespace = 1;
  string object_id = 2;
  string relation = 3;
  string project_id = 4;
  string tenant_id = 5;
  // at_least_consistency_token, when set, demands the expansion reflect state at
  // least as fresh as the token (from a prior WriteRelationTuples). Empty =
  // read latest. A malformed or foreign-shard token is rejected.
  string at_least_consistency_token = 6;
}

message UsersetTree {
  enum NodeType {
    NODE_TYPE_UNSPECIFIED = 0;
    NODE_TYPE_UNION = 1;
    NODE_TYPE_LEAF = 2;
    NODE_TYPE_INTERSECTION = 3;
    // EXCLUSION encodes its operands EXPLICITLY in `include` / `exclude`, NOT
    // in `children`: the effective set is `include` minus `exclude`. `children`
    // is empty for EXCLUSION nodes.
    NODE_TYPE_EXCLUSION = 4;
  }
  NodeType type = 1;
  // For LEAF nodes: the concrete subjects and usersets at this node.
  repeated string user_ids = 2;
  repeated SubjectSet sets = 3;
  // For UNION / INTERSECTION nodes: the child subtrees. Empty for EXCLUSION
  // (which uses `include` / `exclude` instead).
  repeated UsersetTree children = 4;
  // The userset this node expands.
  SubjectSet expanded = 5;
  // For LEAF nodes: true if the public wildcard subject is present.
  bool wildcard = 6;
  // For EXCLUSION nodes only: the include leg (the base set) and the exclude
  // leg (subjects removed from it). The effective set is `include` minus
  // `exclude`. Unset for every other node type.
  UsersetTree include = 7;
  UsersetTree exclude = 8;
}

message ExpandResponse { UsersetTree tree = 1; }

// ListObjects returns the object_ids in a namespace where subject_user_id has
// the relation, for the given project/tenant. The reverse of Check: "which
// courses can this learner see?".
message ListObjectsRequest {
  string namespace = 1;
  string relation = 2;
  string subject_user_id = 3;
  string project_id = 4;
  string tenant_id = 5;
  // at_least_consistency_token, when set, demands the listing reflect state at
  // least as fresh as the token (from a prior WriteRelationTuples). Empty =
  // read latest. A malformed or foreign-shard token is rejected.
  string at_least_consistency_token = 6;
}
message ListObjectsResponse { repeated string object_ids = 1; }

// DeprovisionUser revokes ALL of a user's ACCESS GRANTS: it deletes every
// relation tuple whose concrete subject is user_id, across all namespaces AND
// ALL TENANTS of the project, so a leaving user cannot retain live access in a
// sibling tenant; tenant_id is therefore ignored here. It does NOT delete the
// user's PII (membership/invitation/workspace rows) — full subject erasure and
// data-subject export are a separate concern (issue #14), so this RPC alone is
// not a GDPR/COPPA "right to erasure".
message DeprovisionUserRequest {
  string project_id = 1;
  // tenant_id is accepted for call-shape symmetry but IGNORED: erasure spans
  // every tenant of the project.
  string tenant_id = 2;
  string user_id = 3;
}
message DeprovisionUserResponse { int64 deleted_count = 1; }

// ExportSubjectGrants returns every authorization grant a user holds in a
// project — across ALL tenants (mirroring DeprovisionUser's erase scope) — for
// GDPR/COPPA data-subject access requests. Read-only.
message ExportSubjectGrantsRequest {
  string project_id = 1;
  string user_id = 2;
}
// SubjectGrant is one access grant the subject holds. via_group is empty for a
// DIRECT grant (a tuple whose subject is the user, including the user's group
// memberships); otherwise it is the group id whose membership confers the grant.
message SubjectGrant {
  string tenant_id = 1;
  string namespace = 2;
  string object_id = 3;
  string relation = 4;
  string via_group = 5;
}
message ExportSubjectGrantsResponse { repeated SubjectGrant grants = 1; }

service AuthzService {
  rpc WriteRelationTuples(WriteRelationTuplesRequest) returns (WriteRelationTuplesResponse);
  rpc ReadRelationTuples(ReadRelationTuplesRequest) returns (ReadRelationTuplesResponse);
  rpc Check(CheckRequest) returns (CheckResponse);
  rpc BatchCheck(BatchCheckRequest) returns (BatchCheckResponse);
  rpc Expand(ExpandRequest) returns (ExpandResponse);
  rpc ListObjects(ListObjectsRequest) returns (ListObjectsResponse);
  rpc DeprovisionUser(DeprovisionUserRequest) returns (DeprovisionUserResponse);
  rpc ExportSubjectGrants(ExportSubjectGrantsRequest) returns (ExportSubjectGrantsResponse);
}

// ═══════════════════════════════════════════════════════════════════════
// AdminService — project configuration (platform-operator only)
// ═══════════════════════════════════════════════════════════════════════
//
// A project carries its own authorization model so distinct products run
// distinct namespaces/relations on one deployment. The model is a JSON
// document (see the model schema); an empty model_json means the project uses
// the built-in default model. These RPCs are gated by the platform-operator
// secret (GATEWAY_ADMIN_API_SECRET, presented as the X-Admin-Secret header);
// when the secret is unset they are disabled.

enum ProjectStatus {
  PROJECT_STATUS_UNSPECIFIED = 0;
  PROJECT_STATUS_ACTIVE = 1;
  PROJECT_STATUS_SUSPENDED = 2;
}

message Project {
  string id = 1;
  string name = 2;
  ProjectStatus status = 3;
  // model_json is the project's authorization model as a JSON document. Empty
  // means the project uses the built-in default model.
  string model_json = 4;
  google.protobuf.Timestamp created_at = 5;
  google.protobuf.Timestamp updated_at = 6;
  // data_region, when set, pins the project's data to a region/storage target.
  // An instance configured for a different region refuses to serve it (fail
  // closed). Empty means unpinned. Multi-region storage ROUTING is forward-
  // compat; today this is recorded, validated, and enforced as a serving guard.
  string data_region = 7;
  // max_check_reads, when > 0, overrides the global per-request read budget
  // (GATEWAY_MAX_CHECK_READS) for this project's Check/Expand/ListObjects
  // evaluations. 0 means unset: the project uses the fleet-wide default.
  int32 max_check_reads = 8;
}

message CreateProjectRequest {
  string id = 1;
  string name = 2;
  string model_json = 3;
  string data_region = 4;
  // max_check_reads: 0 leaves the project on the global default budget. A
  // positive value overrides it and must be >= the same floor as the global
  // (see GATEWAY_MAX_CHECK_READS).
  int32 max_check_reads = 5;
}
message CreateProjectResponse { Project project = 1; }

message GetProjectRequest { string id = 1; }
message GetProjectResponse { Project project = 1; }

message UpdateProjectRequest {
  string id = 1;
  string name = 2;
  ProjectStatus status = 3;
  string model_json = 4;
  // data_region: empty leaves it unchanged (like name/model). Setting it
  // repins the project; a repin converges across a horizontally-scaled fleet
  // only after the resolver TTL (~30s), so it is not instantaneous.
  string data_region = 5;
  // clear_data_region, when true, reverts the project to region-agnostic
  // (unpinned). Mutually exclusive with a non-empty data_region. This is the
  // explicit "unpin" path, since an empty data_region means "leave unchanged".
  bool clear_data_region = 6;
  // max_check_reads: 0 leaves it unchanged (like name/model). A positive value
  // overrides the global per-request read budget and must be >= the same floor
  // as the global default. The override converges across a horizontally-scaled
  // fleet only after the resolver TTL (~30s), so it is not instantaneous.
  int32 max_check_reads = 7;
  // clear_max_check_reads, when true, reverts the project to the global default
  // budget. Mutually exclusive with a positive max_check_reads. This is the
  // explicit "reset" path, since a 0 max_check_reads means "leave unchanged".
  bool clear_max_check_reads = 8;
}
message UpdateProjectResponse { Project project = 1; }

message ListProjectsRequest {}
message ListProjectsResponse { repeated Project projects = 1; }

service AdminService {
  rpc CreateProject(CreateProjectRequest) returns (CreateProjectResponse);
  rpc GetProject(GetProjectRequest) returns (GetProjectResponse);
  rpc UpdateProject(UpdateProjectRequest) returns (UpdateProjectResponse);
  rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse);
}

// ═══════════════════════════════════════════════════════════════════════
// SeatService — seat/license/entitlement counting with write-time enforcement
// ═══════════════════════════════════════════════════════════════════════
//
// A seat is one unit of a paid plan (sku) consumed by a user, scoped to
// (project_id, tenant_id) — the B2B "only N paid seats may access this plan"
// need. SetSeatLimit caps how many seats a sku admits; AssignSeat grants one and
// FAILS CLOSED (ResourceExhausted) once the cap is reached. The count check and
// the insert are ONE atomic, race-safe operation, so concurrent assigns can
// never oversubscribe the cap. A sku with no configured limit is unlimited; a
// limit of 0 admits none; clearing the limit (SetSeatLimit with no `limit`)
// returns the sku to unlimited. Assigning an already-seated user is idempotent
// (consumes no extra seat) and re-asserts the backing tuple, so a seat always
// grants access. Each assignment writes a `seat:<sku>#holder@user` relation
// tuple — which can ONLY be minted/removed via AssignSeat/RevokeSeat, never the
// generic WriteRelationTuples (the `seat` namespace is reserved), so the count
// and the granted access cannot diverge.
//
// Downgrade: lowering a limit below current usage is ALLOWED — SetSeatLimit
// succeeds, GetSeatUsage then reports used > limit, and further AssignSeat is
// denied (ResourceExhausted) until enough seats are revoked to drop below the
// new cap. Existing assignments are never auto-revoked.

message SeatLimit {
  string sku = 1;
  // limit is absent when the sku is unlimited (no cap configured); present
  // (including 0, which admits no seats) when a cap is set.
  optional int32 limit = 2;
}

message SeatAssignment {
  string sku = 1;
  string user_id = 2;
  google.protobuf.Timestamp assigned_at = 3;
}

message SetSeatLimitRequest {
  string project_id = 1;
  string tenant_id = 2;
  string sku = 3;
  // limit, when present, sets the cap (must be >= 0; 0 admits no seats). When
  // ABSENT, the sku's limit is CLEARED, returning it to unlimited — this is the
  // only way to undo a previously-set cap.
  optional int32 limit = 4;
}
message SetSeatLimitResponse { SeatLimit limit = 1; }

message GetSeatUsageRequest {
  string project_id = 1;
  string tenant_id = 2;
  string sku = 3;
}
message GetSeatUsageResponse {
  string sku = 1;
  int32 used = 2;
  int32 limit = 3;
  // limited is false when no limit is configured for the sku (unlimited).
  bool limited = 4;
}

message AssignSeatRequest {
  string project_id = 1;
  string tenant_id = 2;
  string sku = 3;
  string user_id = 4;
}
message AssignSeatResponse {
  // already_held is true when the user already had a seat (idempotent no-op).
  bool already_held = 1;
}

message RevokeSeatRequest {
  string project_id = 1;
  string tenant_id = 2;
  string sku = 3;
  string user_id = 4;
}
message RevokeSeatResponse {}

message ListSeatsRequest {
  string project_id = 1;
  string tenant_id = 2;
  string sku = 3;
}
message ListSeatsResponse { repeated SeatAssignment seats = 1; }

service SeatService {
  rpc SetSeatLimit(SetSeatLimitRequest) returns (SetSeatLimitResponse);
  rpc GetSeatUsage(GetSeatUsageRequest) returns (GetSeatUsageResponse);
  rpc AssignSeat(AssignSeatRequest) returns (AssignSeatResponse);
  rpc RevokeSeat(RevokeSeatRequest) returns (RevokeSeatResponse);
  rpc ListSeats(ListSeatsRequest) returns (ListSeatsResponse);
}
