syntax = "proto3";

package heddle.v1;

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

service RepoSyncService {
  rpc ListRefs(ListRefsRequest) returns (ListRefsResponse);
  rpc UpdateRef(UpdateRefRequest) returns (UpdateRefResponse);
  rpc Push(stream PushMessage) returns (stream PushMessage);
  rpc Pull(stream PullMessage) returns (stream PullMessage);
}

service HostedUserService {
  rpc GetCurrentUserNamespace(GetCurrentUserNamespaceRequest) returns (HostedNamespace);
  rpc CreateNamespace(CreateNamespaceRequest) returns (HostedNamespace);
  rpc ListNamespaces(ListNamespacesRequest) returns (ListNamespacesResponse);
  rpc UpdateNamespace(UpdateNamespaceRequest) returns (HostedNamespace);
  rpc DeleteNamespace(DeleteNamespaceRequest) returns (DeleteResponse);
  rpc CreateRepository(CreateRepositoryRequest) returns (HostedRepository);
  rpc ListRepositories(ListRepositoriesRequest) returns (ListRepositoriesResponse);
  rpc UpdateRepository(UpdateRepositoryRequest) returns (HostedRepository);
  rpc DeleteRepository(DeleteRepositoryRequest) returns (DeleteResponse);
  rpc CreateGrant(CreateGrantRequest) returns (HostedGrant);
  rpc ListGrants(ListGrantsRequest) returns (ListGrantsResponse);
  rpc UpdateGrant(UpdateGrantRequest) returns (HostedGrant);
  rpc DeleteGrant(DeleteGrantRequest) returns (DeleteResponse);

  // ── Thread merge approvals ───────────────────────────────────────
  rpc ApproveThread(ApproveThreadRequest) returns (ThreadApproval);
  rpc RevokeApproval(RevokeApprovalRequest) returns (DeleteResponse);
  rpc ListThreadApprovals(ListThreadApprovalsRequest) returns (ListThreadApprovalsResponse);
  rpc CheckMergeEligibility(CheckMergeEligibilityRequest) returns (CheckMergeEligibilityResponse);

  // ── Namespace-scoped invitations and members (formerly admin) ────
  rpc CreateInvitation(CreateInvitationRequest) returns (Invitation);
  rpc ListInvitations(ListInvitationsRequest) returns (ListInvitationsResponse);
  rpc RevokeInvitation(RevokeInvitationRequest) returns (DeleteResponse);
  rpc ListMembers(ListMembersRequest) returns (ListMembersResponse);

  // ── Approval groups (reusable approver pools) ────────────────────
  // Admin role on the repo's parent namespace required to create or
  // mutate; Reader is enough to list.
  rpc CreateApprovalGroup(CreateApprovalGroupRequest) returns (ApprovalGroup);
  rpc ListApprovalGroups(ListApprovalGroupsRequest) returns (ListApprovalGroupsResponse);
  rpc DeleteApprovalGroup(DeleteApprovalGroupRequest) returns (DeleteResponse);
  rpc AddApprovalGroupMember(AddApprovalGroupMemberRequest) returns (ApprovalGroupMember);
  rpc RemoveApprovalGroupMember(RemoveApprovalGroupMemberRequest) returns (DeleteResponse);

  // ── Thread policies (branch protection) ──────────────────────────
  rpc CreateThreadPolicy(CreateThreadPolicyRequest) returns (ThreadPolicy);
  rpc ListThreadPolicies(ListThreadPoliciesRequest) returns (ListThreadPoliciesResponse);
  rpc DeleteThreadPolicy(DeleteThreadPolicyRequest) returns (DeleteResponse);
  rpc AddPolicyGroupRequirement(AddPolicyGroupRequirementRequest) returns (PolicyGroupRequirement);
  rpc RemovePolicyGroupRequirement(RemovePolicyGroupRequirementRequest) returns (DeleteResponse);

  // ── Operator-only listings (gate on staff scope) ─────────────────
  rpc ListActors(ListActorsRequest) returns (ListActorsResponse);
  rpc ListWorktrees(ListWorktreesRequest) returns (ListWorktreesResponse);

  // ── Support access — customer-issued temporary admin grants for
  // Heddle staff. The grantor must hold Admin on the namespace/repo;
  // the grantee is a staff email. The server materialises a real
  // `hosted_grants` row plus a `support_access_audit` row, both
  // bounded by the supplied TTL.
  rpc GrantSupportAccess(GrantSupportAccessRequest) returns (SupportAccessGrant);
  rpc ListSupportAccessGrants(ListSupportAccessGrantsRequest) returns (ListSupportAccessGrantsResponse);
  rpc RevokeSupportAccess(RevokeSupportAccessRequest) returns (DeleteResponse);
}

service AuthService {
  rpc BeginWebAuthnRegistration(BeginWebAuthnRegistrationRequest) returns (AuthChallengeResponse);
  rpc RegisterPublicKey(RegisterPublicKeyRequest) returns (AccessTokenResponse);
  rpc BeginWebAuthnAuthentication(BeginWebAuthnAuthenticationRequest) returns (AuthChallengeResponse);
  rpc FinishWebAuthnAuthentication(FinishWebAuthnAuthenticationRequest) returns (AccessTokenResponse);
  rpc CreateDeviceAuthorization(CreateDeviceAuthorizationRequest) returns (DeviceAuthorizationResponse);
  rpc ApproveDeviceAuthorization(ApproveDeviceAuthorizationRequest) returns (AccessTokenResponse);
  rpc ExchangeDeviceAuthorization(ExchangeDeviceAuthorizationRequest) returns (AccessTokenResponse);
  rpc WaitForDeviceAuthorization(WaitForDeviceAuthorizationRequest) returns (stream DeviceAuthorizationEvent);
  rpc RotateCredential(RotateCredentialRequest) returns (IssuedCredentialResponse);
  rpc RevokeCredential(RevokeCredentialRequest) returns (RevokeCredentialResponse);
  rpc CreateServiceAccount(CreateServiceAccountRequest) returns (ServiceAccountResponse);
  rpc IssueServiceAccountCredential(IssueServiceAccountCredentialRequest) returns (IssuedCredentialResponse);
  rpc RevokeServiceAccount(RevokeServiceAccountRequest) returns (DeleteResponse);
  rpc WhoAmI(WhoAmIRequest) returns (WhoAmIResponse);
  rpc IntrospectCredential(IntrospectCredentialRequest) returns (CredentialInfo);
  rpc ListServiceAccounts(ListServiceAccountsRequest) returns (ListServiceAccountsResponse);
  rpc ListScopeCapabilities(ListScopeCapabilitiesRequest) returns (ListScopeCapabilitiesResponse);
  rpc LinkOAuthIdentity(LinkOAuthIdentityRequest) returns (AccessTokenResponse);
  rpc StoreProviderToken(StoreProviderTokenRequest) returns (StoreProviderTokenResponse);
  rpc AnalyzeExternalDiff(AnalyzeExternalDiffRequest) returns (AnalyzeExternalDiffResponse);

  // Public lookup of an invitation's shape (namespace path + role) by id.
  // Intentionally exposes no PII (email, invited_by) so the unauth'd
  // `/invite/[id]` page can still show a useful CTA. Returns
  // `exists: false, valid: false` when the id is unknown or malformed.
  rpc GetInvitationSummary(GetInvitationSummaryRequest) returns (InvitationSummary);

  // List the caller's active sessions. Drives the Active Sessions
  // UI (Settings → Active sessions). Surfaces user-agent
  // and IP captured at issue time + last-used timestamp so users
  // can spot unfamiliar devices.
  rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse);

  // Revoke a single Biscuit session by rev_id. Per-row revoke from
  // the Active Sessions UI; also used by the logout flow on the
  // caller's own session. Pushes the rev_id into `session_blocklist`
  // and broadcasts via Postgres LISTEN/NOTIFY so every server
  // instance's revocation cache rejects the next request.
  rpc RevokeSession(RevokeSessionRequest) returns (DeleteResponse);

  // Mint a fresh Biscuit for a long-lived process (CLI, service
  // account, agent). Unauthenticated at the bearer level: the proof
  // in the request is the authentication. Two proof types are
  // supported. KeypairProof has the caller sign
  // (timestamp, public_key_id, requested_scope) with the Ed25519
  // private key matching a device_roots.public_key or
  // issued_credentials.public_key row; this is the autonomous
  // renewal path used by long-lived CLI processes. DeviceAuthProof
  // completes a user-approved device authorization handoff — the
  // CLI presents the device_code returned by
  // CreateDeviceAuthorization plus a signature over device:{code}.
  // The requested_scope field is an attenuation hint: the server
  // clamps it down to what the keypair owner was originally granted
  // — never up.
  rpc MintBiscuit(MintBiscuitRequest) returns (AccessTokenResponse);

  // Issue a short-lived presence-scoped Biscuit derived from the
  // caller's existing session bearer. The returned token is the
  // caller's parent token with an attenuation block that pins
  // `operation == "presence"` and a 5-minute hard expiry. Used by
  // the web app's presence WebSocket flow so the browser never has
  // to expose its long-lived `heddle_session` bearer to client JS:
  // the SvelteKit server-side reads the HttpOnly cookie, calls this
  // RPC, and returns the narrow token to the browser, which embeds
  // it in the WS upgrade URL. If the narrow token leaks (XSS, log
  // capture) it can only subscribe to presence and only for 5
  // minutes — and revoking the parent session kills it via cascade
  // rev-id rejection.
  rpc IssuePresenceToken(IssuePresenceTokenRequest) returns (IssuePresenceTokenResponse);

  // Mint a fresh anonymous Biscuit for a browser that has no
  // session cookie yet. Unauthenticated — the response IS the
  // identity. The returned biscuit carries `subject_kind("anon")`
  // and a fresh `Anon(uuid)` subject; tapestry base64-encodes the
  // bytes and emits `Set-Cookie: weft_auth=<…>; HttpOnly; Secure;
  // SameSite=Lax`. Subsequent calls from the same browser ride on
  // this biscuit as a normal bearer; later sign-in is an
  // anon→user promotion (mint a new user-subject biscuit, revoke
  // the anon rev_id). Per-IP rate-limited; when
  // `WEFT_MINT_TURNSTILE_REQUIRED=true`, requires a valid
  // Turnstile token. See HeddleCo/weft#189 and
  // `docs/auth/anon-biscuit-spike.md` §3 + §7.1.
  rpc MintAnonBiscuit(MintAnonBiscuitRequest) returns (MintAnonBiscuitResponse);
}

service ContentService {
  rpc GetRefs(GetRefsRequest) returns (GetRefsResponse);
  rpc ListStates(ListStatesRequest) returns (ListStatesResponse);
  rpc GetState(GetStateRequest) returns (StateSummary);
  rpc GetBlame(GetBlameRequest) returns (GetBlameResponse);
  rpc ListProvenanceSummaries(ListProvenanceSummariesRequest) returns (ListProvenanceSummariesResponse);
  rpc GetTree(GetTreeRequest) returns (TreeResponse);
  rpc GetBlob(GetBlobRequest) returns (BlobResponse);
  rpc GetCompare(GetCompareRequest) returns (CompareResponse);
  rpc GetDiff(GetStateRequest) returns (DiffResponse);
  // Walk recent history and aggregate semantic-change events to surface
  // the files or functions with the most activity.
  rpc GetSemanticHotSpots(GetSemanticHotSpotsRequest) returns (GetSemanticHotSpotsResponse);
  rpc ListActions(GetStateRequest) returns (ActionsResponse);
  rpc ListContext(ListContextRequest) returns (ListContextResponse);
  rpc GetContextHistory(GetContextHistoryRequest) returns (GetContextHistoryResponse);
  rpc ListContextSuggestions(ListContextSuggestionsRequest) returns (ListContextSuggestionsResponse);
  rpc SetContext(SetContextRequest) returns (SetContextResponse);
  rpc ReviseContext(ReviseContextRequest) returns (ReviseContextResponse);
  rpc SupersedeContext(SupersedeContextRequest) returns (SupersedeContextResponse);
}

service RepoEventService {
  rpc SubscribeRepoEvents(SubscribeRepoEventsRequest) returns (stream RepoEvent);
}

service ThreadWorkflowService {
  rpc StartThread(StartThreadRequest) returns (ThreadSummary);
  rpc ListThreads(ListThreadsRequest) returns (ListThreadsResponse);
  rpc GetWorkspaceSummary(GetWorkspaceSummaryRequest) returns (WorkspaceSummary);
  rpc StreamWorkspaceSummary(GetWorkspaceSummaryRequest) returns (stream WorkspaceSummary);
  rpc GetThread(GetThreadRequest) returns (ThreadSummary);
  rpc CaptureThread(CaptureThreadRequest) returns (CaptureThreadResponse);
  rpc CompleteThread(CompleteThreadRequest) returns (ThreadSummary);
  rpc WatchThreads(WatchThreadsRequest) returns (stream ThreadEvent);
}

service ReviewService {
  rpc StartReviewAnalysis(StartReviewAnalysisRequest) returns (StartReviewAnalysisResponse);
  rpc GetReviewAnalysisStatus(GetReviewAnalysisStatusRequest) returns (GetReviewAnalysisStatusResponse);
  rpc GetReviewAnalysisResult(GetReviewAnalysisResultRequest) returns (GetReviewAnalysisResultResponse);
}

message RefEntry {
  string name = 1;
  bytes change_id = 2;
  bool is_thread = 3;
}

message ListRefsRequest {
  string repo_path = 1;
}

message ListRefsResponse {
  string head_thread = 1;
  bytes head_state = 2;
  repeated RefEntry refs = 3;
}

message UpdateRefRequest {
  string repo_path = 1;
  string name = 2;
  bool is_thread = 3;
  bool force = 4;
  string old_value = 5;
  string new_value = 6;
  ThreadMetadata thread_metadata = 7;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message UpdateRefResponse {
  bool success = 1;
  string old_value = 2;
  string error = 3;
}

enum TransportMode {
  TRANSPORT_MODE_UNSPECIFIED = 0;
  // Native pack is the steady-state sync transport for hosted repo pull/push.
  TRANSPORT_MODE_NATIVE_PACK = 1;
}

enum ObjectAvailabilityStatus {
  OBJECT_AVAILABILITY_STATUS_UNSPECIFIED = 0;
  OBJECT_AVAILABILITY_STATUS_PRESENT = 1;
  OBJECT_AVAILABILITY_STATUS_MISSING = 2;
  OBJECT_AVAILABILITY_STATUS_RESUMABLE = 3;
  OBJECT_AVAILABILITY_STATUS_LAZY = 4;
}

enum PartialFetchStatus {
  PARTIAL_FETCH_STATUS_UNSPECIFIED = 0;
  PARTIAL_FETCH_STATUS_DISABLED = 1;
  PARTIAL_FETCH_STATUS_ENABLED = 2;
  PARTIAL_FETCH_STATUS_REQUIRED = 3;
}

message TransferCheckpoint {
  string transfer_id = 1;
  TransportMode transport_mode = 2;
  uint64 resume_offset = 3;
  uint32 chunk_index = 4;
  bytes checkpoint = 5;
  bool is_complete = 6;
}

message ObjectDescriptor {
  string id = 1;
  string object_type = 2;
  ObjectAvailabilityStatus availability_status = 3;
  string availability_note = 4;
}

message PushRequest {
  string repo_path = 1;
  string local_state = 2;
  string target_thread = 3;
  bool create_thread = 4;
  bool force = 5;
  repeated ObjectDescriptor objects = 6;
  TransferCheckpoint transfer = 7;
  PartialFetchStatus partial_fetch_status = 8;
  bool allow_partial_fetch = 9;
  ThreadMetadata thread_metadata = 10;
  // Idempotency on the stream-opening message. The PushMessage
  // envelope itself isn't the operation — PushRequest is.
  string client_operation_id = 15;
}

message PushReady {
  string remote_head = 1;
  repeated ObjectDescriptor have_objects = 2;
  repeated ObjectDescriptor want_objects = 3;
  TransferCheckpoint transfer = 4;
  PartialFetchStatus partial_fetch_status = 5;
  repeated ObjectDescriptor missing_objects = 6;
}

// Legacy object-by-object chunking is no longer used for normal repo sync.
// The schema stays here only for narrow, non-steady-state object transfer paths.
message ObjectChunk {
  string id = 1;
  string object_type = 2;
  bytes data = 3;
  bool is_delta = 4;
  TransferCheckpoint transfer = 5;
  uint32 chunk_length = 6;
  bool is_final_chunk = 7;
}

enum PackStreamKind {
  PACK_STREAM_KIND_UNSPECIFIED = 0;
  PACK_STREAM_KIND_PACK = 1;
  PACK_STREAM_KIND_INDEX = 2;
}

message PackChunk {
  PackStreamKind stream_kind = 1;
  bytes data = 2;
  TransferCheckpoint transfer = 3;
  uint32 chunk_length = 4;
  bool is_final_chunk = 5;
}

message PushComplete {
  bool success = 1;
  string new_state = 2;
  string error = 3;
  TransferCheckpoint transfer = 4;
}

// Out-of-pack transport for redaction sidecars. Sidecars live
// structurally outside `.heddle/objects/` (so GC can't reach them)
// and therefore can't ride the native-pack object channel. The
// sender emits one `RedactionTransfer` per blob hash with an active
// redaction in the state closure; the receiver routes through
// `Repository::accept_wire_redactions` for signature + trust-list
// verification.
//
// `blob_hash` is the hex-encoded ContentHash of the *redacted blob*
// (the redactions store is keyed by that hash, not by the
// redaction-record content hash).
// `redactions_blob` is the rmp-encoded `RedactionsBlob` byte payload
// — byte-identical to what `Repository::put_redaction` wrote on the
// sender's disk.
message RedactionTransfer {
  string blob_hash = 1;
  bytes redactions_blob = 2;
}

message PushMessage {
  oneof body {
    PushRequest request = 1;
    PushReady ready = 2;
    PackChunk pack = 3;
    PushComplete complete = 4;
    RedactionTransfer redaction = 5;
  }
}

message PullRequest {
  string repo_path = 1;
  string remote_thread = 2;
  string local_thread = 3;
  string target_state = 4;
  uint32 depth = 5;
  repeated string exclude_states = 6;
  TransferCheckpoint transfer = 7;
  PartialFetchStatus partial_fetch_status = 8;
  bool allow_partial_fetch = 9;
  bool fresh_full_pull = 10;
  // Idempotency on the stream-opening message. The PullMessage
  // envelope itself isn't the operation — PullRequest is.
  string client_operation_id = 15;
}

message PullReady {
  string remote_state = 1;
  repeated ObjectDescriptor objects_to_fetch = 2;
  TransferCheckpoint transfer = 3;
  PartialFetchStatus partial_fetch_status = 4;
  repeated ObjectDescriptor missing_objects = 5;
  bool full_closure_available = 6;
  uint32 object_count = 7;
}

message WantObjects {
  repeated ObjectDescriptor objects = 1;
  TransferCheckpoint transfer = 2;
  bool want_full_closure = 3;
}

message PullComplete {
  bool success = 1;
  string new_state = 2;
  string error = 3;
  TransferCheckpoint transfer = 4;
}

message PullMessage {
  oneof body {
    PullRequest request = 1;
    PullReady ready = 2;
    WantObjects want = 3;
    PackChunk pack = 4;
    PullComplete complete = 5;
    RedactionTransfer redaction = 6;
  }
}

// Namespace kinds in the hosted control plane. `USER` namespaces are
// per-account and auto-created at first sign-in; `ORG` and `TEAM` are
// explicit organisational containers.
enum NamespaceKind {
  NAMESPACE_KIND_UNSPECIFIED = 0;
  NAMESPACE_KIND_USER = 1;
  NAMESPACE_KIND_ORG = 2;
  NAMESPACE_KIND_TEAM = 3;
}

// Five-level role grants on hosted namespaces and repositories. Used
// wherever a role appears on the wire — `HostedGrant.role`,
// `Invitation.role`, `ApprovalGroupMember.required_role`,
// `ThreadPolicy.required_role`, etc.
enum HostedRole {
  HOSTED_ROLE_UNSPECIFIED = 0;
  HOSTED_ROLE_READER = 1;
  HOSTED_ROLE_DEVELOPER = 2;
  HOSTED_ROLE_MAINTAINER = 3;
  HOSTED_ROLE_ADMIN = 4;
  HOSTED_ROLE_OWNER = 5;
}

// Review-signature kinds. `READ` is a human reviewer; the agent variants
// distinguish what an AI signing a state was actually doing — preview
// (passive read-through) vs co-review (mutating suggestions).
enum ReviewKind {
  REVIEW_KIND_UNSPECIFIED = 0;
  REVIEW_KIND_READ = 1;
  REVIEW_KIND_AGENT_PREVIEW = 2;
  REVIEW_KIND_AGENT_CO_REVIEW = 3;
}

// State publication status. Captures whether a state is in-progress
// (`DRAFT`) or pinned as the published shape of the work (`PUBLISHED`).
enum StateStatus {
  STATE_STATUS_UNSPECIFIED = 0;
  STATE_STATUS_DRAFT = 1;
  STATE_STATUS_PUBLISHED = 2;
}

message ListNamespacesRequest {}
message GetCurrentUserNamespaceRequest {}
message CreateNamespaceRequest {
  NamespaceKind kind = 1;
  string slug = 2;
  string parent_path = 3;
  string display_name = 4;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message UpdateNamespaceRequest {
  string full_path = 1;
  string new_slug = 2;
  string display_name = 3;
  bool clear_display_name = 4;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message DeleteNamespaceRequest {
  string full_path = 1;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message ListRepositoriesRequest {
  string namespace_path = 1;
}
message CreateRepositoryRequest {
  string namespace_path = 1;
  string slug = 2;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message UpdateRepositoryRequest {
  string full_path = 1;
  string new_slug = 2;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message DeleteRepositoryRequest {
  string full_path = 1;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message ListGrantsRequest {
  string resource = 1;
}

// Grants are scoped to either a namespace or a repository — never both,
// never neither. The `target` oneof is the wire-level form of that
// "exactly one of" guarantee. Server-side `classify_grant_target`
// translates it to a `GrantTarget` enum without runtime branching.
message GrantTargetRef {
  oneof target {
    string namespace_path = 1;
    string repo_path = 2;
  }
}
message CreateGrantRequest {
  string subject = 1;
  HostedRole role = 2;
  GrantTargetRef target = 3;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message UpdateGrantRequest {
  string subject = 1;
  HostedRole role = 2;
  GrantTargetRef target = 3;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message DeleteGrantRequest {
  string subject = 1;
  GrantTargetRef target = 2;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message DeleteResponse {
  bool deleted = 1;
}
message ListActorsRequest {}
message ListWorktreesRequest {}
message HostedNamespace {
  string namespace_id = 1;
  NamespaceKind kind = 2;
  string slug = 3;
  string full_path = 4;
  string parent_id = 5;
  string display_name = 6;
}
message HostedRepository {
  string repo_id = 1;
  string namespace_id = 2;
  string slug = 3;
  string path = 4;
  string full_path = 5;
}
message HostedGrant {
  string subject = 1;
  HostedRole role = 2;
  GrantTargetRef target = 3;
}
message ActorSummary {
  string session_id = 1;
  string thread = 2;
  optional string path = 3;
  bytes base_state = 4;
  google.protobuf.Timestamp started_at = 5;
  optional string provider = 6;
  optional string model = 7;
  string status = 8;
  optional string heddle_session_id = 9;
  optional string harness = 10;
  optional string thinking_level = 11;
  UsageSummary usage_summary = 12;
  optional google.protobuf.Timestamp last_progress_at = 13;
  optional string report_flush_state = 14;
  optional string client_instance_id = 15;
  optional string attach_reason = 16;
  optional string native_actor_key = 17;
  optional string native_parent_actor_key = 18;
  optional string probe_source = 19;
  optional float probe_confidence = 20;
}
message WorktreeSummary {
  string id = 1;
  string name = 2;
  bytes base_state = 3;
  optional string thread = 4;
  uint32 changes = 5;
  optional string session_id = 6;
  google.protobuf.Timestamp created_at = 7;
  google.protobuf.Timestamp modified_at = 8;
  bool is_current = 9;
}
message ListActorsResponse {
  repeated ActorSummary actors = 1;
}
message ListWorktreesResponse {
  repeated WorktreeSummary worktrees = 1;
}

// --- Invitations (Track D) -------------------------------------------------

message Invitation {
  string id = 1;
  string email = 2;
  string namespace_path = 3;
  HostedRole role = 4;
  string invited_by = 5;
  google.protobuf.Timestamp created_at = 6; // unix seconds
  google.protobuf.Timestamp expires_at = 7; // 0 if no expiry
  google.protobuf.Timestamp accepted_at = 8; // 0 if pending
  string accepted_by = 9; // "" if pending
  string metadata = 10; // stringified JSON — small, avoids pulling in struct
}

message CreateInvitationRequest {
  string email = 1;
  string namespace_path = 2;
  HostedRole role = 3;
  google.protobuf.Timestamp expires_at = 4; // 0 = no expiry
  string metadata = 5; // optional caller-supplied JSON; pass "{}" if none

  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message ListInvitationsRequest {
  string namespace_path = 1;
  bool include_accepted = 2; // default false = pending only
}

message ListInvitationsResponse {
  repeated Invitation invitations = 1;
}

message RevokeInvitationRequest {
  string id = 1;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

// Members = direct grants + pending invitations for a namespace.
message MemberEntry {
  // Exactly one of these is populated.
  oneof entry {
    Grant grant = 1;
    Invitation invitation = 2;
  }
  message Grant {
    string subject = 1;
    HostedRole role = 2;
    string resource_path = 3;
    string resource_kind = 4; // "namespace" or "repository"
  }
}

message ListMembersRequest {
  string namespace_path = 1;
  bool include_descendants = 2; // include grants on child namespaces + repos
}

message ListMembersResponse {
  repeated MemberEntry entries = 1;
}

message StartReviewAnalysisRequest {
  string provider = 1;
  string owner = 2;
  string repo = 3;
  uint32 pr_number = 4;
  bool force_refresh = 5;
}

message StartReviewAnalysisResponse {
  string job_id = 1;
  string head_sha = 2;
}

enum ReviewJobStatus {
  REVIEW_JOB_STATUS_UNSPECIFIED = 0;
  REVIEW_JOB_STATUS_PENDING = 1;
  REVIEW_JOB_STATUS_ANALYZING = 2;
  REVIEW_JOB_STATUS_POSTED = 3;
  REVIEW_JOB_STATUS_FAILED = 4;
}

enum ReviewJobPhase {
  REVIEW_JOB_PHASE_UNSPECIFIED = 0;
  REVIEW_JOB_PHASE_FETCH = 1;
  REVIEW_JOB_PHASE_HIGHLIGHT = 2;
  REVIEW_JOB_PHASE_FINALIZE = 3;
  REVIEW_JOB_PHASE_COMPLETE = 4;
  REVIEW_JOB_PHASE_FAILED = 5;
}

message GetReviewAnalysisStatusRequest {
  string job_id = 1;
  string provider = 2;
  string owner = 3;
  string repo = 4;
  uint32 pr_number = 5;
}

message GetReviewAnalysisStatusResponse {
  string job_id = 1;
  ReviewJobStatus status = 2;
  ReviewJobPhase phase = 3;
  uint32 current = 4;
  uint32 total = 5;
  string label = 6;
  google.protobuf.Timestamp updated_at = 7;
  string head_sha = 8;
}

message ReviewPrAuthor {
  string login = 1;
  string avatar_url = 2;
}

message ReviewPrLabel {
  string name = 1;
  string color = 2;
}

message ReviewPrMetadata {
  string title = 1;
  string body = 2;
  string state = 3;
  bool draft = 4;
  uint32 changed_files = 5;
  uint32 additions = 6;
  uint32 deletions = 7;
  string head_sha = 8;
  string base_branch = 9;
  string head_branch = 10;
  ReviewPrAuthor author = 11;
  repeated ReviewPrLabel labels = 12;
  google.protobuf.Timestamp created_at = 13;
  google.protobuf.Timestamp updated_at = 14;
  google.protobuf.Timestamp merged_at = 15;
}

message ReviewContributor {
  string name = 1;
  string email = 2;
  bool is_agent = 3;
}

message ReviewNoiseFile {
  string path = 1;
  string reason = 2;
  uint32 additions = 3;
  uint32 deletions = 4;
}

message ReviewSemanticChange {
  string change_type = 1;
  string description = 2;
  string path = 3;
  uint32 additions = 4;
  uint32 deletions = 5;
  uint32 impact = 6;
}

message ReviewPacket {
  uint32 files_changed = 1;
  uint32 semantic_changes = 2;
  uint32 noise_filtered = 3;
  repeated ReviewSemanticChange changes = 4;
  repeated ReviewNoiseFile noise = 5;
  repeated ReviewContributor contributors = 6;
  uint32 agents = 7;
  uint32 humans = 8;
  string narrative = 9;
}

message ReviewCommentAuthor {
  string login = 1;
  string avatar_url = 2;
}

message ReviewComment {
  ReviewCommentAuthor author = 1;
  string body = 2;
  google.protobuf.Timestamp created_at = 3;
  bool is_review_comment = 4;
}

message ReviewFileArtifact {
  uint32 index = 1;
  string change_type = 2;
  string description = 3;
  string path = 4;
  uint32 additions = 5;
  uint32 deletions = 6;
  uint32 impact = 7;
  string patch = 8;
  bool truncated = 9;
  repeated string highlights = 10;
}

message GetReviewAnalysisResultRequest {
  string job_id = 1;
  string provider = 2;
  string owner = 3;
  string repo = 4;
  uint32 pr_number = 5;
}

message GetReviewAnalysisResultResponse {
  string job_id = 1;
  string head_sha = 2;
  ReviewPrMetadata metadata = 3;
  ReviewPacket packet = 4;
  repeated ReviewComment comments = 5;
  repeated ReviewFileArtifact files = 6;
  google.protobuf.Timestamp completed_at = 7;
}
message BeginWebAuthnRegistrationRequest {
  string username = 1;
  string display_name = 2;
}
message RegisterPublicKeyRequest {
  string challenge_id = 1;
  // Raw bytes per WebAuthn (clientDataJSON / attestationObject / authenticatorData
  // / signature are passed verbatim; the WebAuthn lib base64url-decodes them
  // off the wire).
  bytes client_data_json = 2;
  bytes attestation_object = 3;
  string device_label = 4;
  // Raw 32-byte Ed25519 public key the client commits to as the
  // renewal anchor for this device. Distinct from the WebAuthn
  // credential's attested public key (COSE-encoded, lives in
  // `webauthn_credentials`): this key lives in `device_roots` and
  // is the one `mint_biscuit_keypair` calls
  // `Ed25519Signer::verify_with_public_key` against on every
  // `KeypairProof` renewal. Leave empty for browser-only flows.
  //
  // When non-empty, MUST be accompanied by a binding signature
  // (see the three `device_binding_*` fields). Closes
  // HeddleCo/weft#131.
  bytes device_public_key = 5;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
  // === Device-key binding proof (HeddleCo/weft#131) ===
  // Required iff `device_public_key` is non-empty. Together these
  // carry a WebAuthn assertion (`navigator.credentials.get()`
  // produced immediately after `create()` succeeds, signed by the
  // same authenticator) whose challenge is
  //   base64url(SHA256("heddle-device-binding-v1" || 0x00 || device_public_key))
  // The server verifies the assertion's signature using the COSE
  // public key extracted from the attestation, then persists
  // `device_public_key` as the device root. Binds the two keys
  // cryptographically: the renewal-path public key is only
  // accepted if the WebAuthn credential signs it.
  bytes device_binding_client_data_json = 16;
  bytes device_binding_authenticator_data = 17;
  bytes device_binding_signature = 18;
}
message BeginWebAuthnAuthenticationRequest {
  string username = 1;
}
message FinishWebAuthnAuthenticationRequest {
  string challenge_id = 1;
  string credential_id = 2;
  bytes client_data_json = 3;
  bytes authenticator_data = 4;
  bytes signature = 5;
  bytes user_handle = 6;
  // Client-side device pubkey the client wants the access token bound
  // to. Server validates this against the user's active device_roots.
  bytes device_public_key = 7;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message CreateDeviceAuthorizationRequest {
  string device_name = 1;
  bytes device_public_key = 2;
  string scope = 3;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message ApproveDeviceAuthorizationRequest {
  string user_code = 1;
  string provider = 2;
  string model = 3;
  bool default_agent_policy = 4;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message ExchangeDeviceAuthorizationRequest {
  string device_code = 1;
  bytes device_public_key = 2;
  // Raw signature bytes over `device:{device_code}` using the device key.
  bytes proof = 3;
}
message WaitForDeviceAuthorizationRequest {
  string device_code = 1;
}

message DeviceAuthorizationEvent {
  string status = 1;
}

message RotateCredentialRequest {
  string credential_id = 1;
  bytes public_key = 2;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message RevokeCredentialRequest {
  string credential_id = 1;
  string reason = 2;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message RevokeCredentialResponse {
  bool revoked = 1;
}
message CreateServiceAccountRequest {
  string subject = 1;
  string display_name = 2;
  string scope = 3;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message IssueServiceAccountCredentialRequest {
  string service_account_id = 1;
  bytes public_key = 2;
  string scope = 3;
  // 0 = never expires. Otherwise the credential Biscuit's expiry and
  // the `issued_credentials.expires_at` row are both set to now + ttl_secs.
  google.protobuf.Duration ttl_secs = 4;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message AuthChallengeResponse {
  string challenge_id = 1;
  string challenge = 2;
  google.protobuf.Timestamp expires_at = 3;
  string username = 4;
  string rp_id = 5;
  string rp_name = 6;
  string user_id = 7;
  string display_name = 8;
  repeated string allow_credential_ids = 9;
  string attestation = 10;
  string user_verification = 11;
}
message DeviceAuthorizationResponse {
  string device_code = 1;
  string user_code = 2;
  string verification_uri = 3;
  google.protobuf.Timestamp expires_at = 4;
}
message AccessTokenResponse {
  // The Biscuit (base64url-encoded). Goes in the `heddle_session`
  // cookie for browser flows and in the CLI credentials store for
  // device-bound flows.
  string token = 1;
  string subject = 2;
  // Unix-seconds expiry. The Biscuit also carries this internally,
  // but exposing it explicitly lets clients know when to renew
  // without parsing the token.
  google.protobuf.Timestamp expires_at = 3;
  // The Biscuit's `session()` fact (also its revocation id). Stable
  // public id surfaced in the Active Sessions UI; argument to
  // `RevokeSession`.
  string session_id = 4;
  repeated string amr = 5;
  // `issued_credentials.id` for tokens minted from a long-lived
  // credential row (device, service-account, agent). Empty for
  // browser sessions.
  string credential_id = 6;
  // Server-issued GrantEnvelope authenticating the client's pubkey +
  // session for biscuit minting. Empty on legacy / unauthenticated
  // responses.
  bytes grant_envelope = 7;
}

message ListSessionsRequest {}

message ListSessionsResponse {
  repeated ActiveSession sessions = 1;
}

message ActiveSession {
  // Public id of the refresh token (matches `auth_refresh_tokens.id`).
  // Used by RevokeSession to identify which row to revoke.
  string id = 1;
  // True for the session this RPC was invoked on, so the UI can
  // mark it "Current" and disable the revoke button (revoking
  // yourself is just logout — the dedicated logout flow handles
  // cookie cleanup correctly).
  bool is_current = 2;
  // Captured at issue time. Surfaced in the device list as a
  // human-readable string ("Chrome on macOS", "Safari on iPhone").
  // The web layer parses to that shape for display; the raw UA
  // header is what's stored.
  string user_agent = 3;
  // Captured at issue time. Empty string when unknown.
  string ip = 4;
  google.protobuf.Timestamp issued_at = 5;
  google.protobuf.Timestamp expires_at = 6;
  // Last time this token was rotated (i.e. the user actively used
  // the app from this session). Zero when never rotated.
  google.protobuf.Timestamp last_used_at = 7;
}

message RevokeSessionRequest {
  // The session id to revoke (from ActiveSession.id).
  string session_id = 1;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

// Caller authenticates with their normal session bearer in the
// `Authorization: Bearer ...` metadata (same path as every other
// gRPC). No body fields — the response is purely a function of the
// caller's identity and a fixed 5-minute TTL.
message IssuePresenceTokenRequest {
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message IssuePresenceTokenResponse {
  // base64-encoded narrow Biscuit. Embed in the WS upgrade URL as
  // `?token=<this>`. Disposable on the caller's side after the WS
  // connection is established.
  string token = 1;

  // Unix-seconds wall-clock expiry. Always 5 minutes from issue.
  // Surfacing it lets the client know when to re-mint without
  // parsing the token.
  google.protobuf.Timestamp expires_at = 2;
}

// MintBiscuit is the unified renewal endpoint for long-lived
// processes. The caller proves they're entitled to a fresh Biscuit
// via one of the proof variants below. See `AuthService.MintBiscuit`
// for the full semantic note.
message MintBiscuitRequest {
  // The principal subject that the new Biscuit should bind to. Must
  // match the keypair's recorded subject (or the device_root's
  // user). Server rejects mismatches as `permission_denied` rather
  // than minting a token for an arbitrary subject.
  string subject = 1;

  // Attenuation hint: when set, the server intersects the requested
  // scope with the keypair-owner's original grant. Empty == reuse
  // the original scope.
  string requested_scope = 2;

  // UA / IP captured by the caller. Same shape and intent as
  // `RefreshSessionRequest` had — usually empty (the web layer's
  // `x-forwarded-*` metadata path is preferred), but kept available
  // for direct CLI callers that don't speak the proxy convention.
  string user_agent = 3;
  string ip = 4;

  oneof proof {
    KeypairProof keypair = 10;
    DeviceAuthProof device_auth = 11;
    // OidcProof oidc = 12;  // Future: federated CI token exchange.
  }

  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

// Proof of possession via Ed25519 signature. The server looks up
// `public_key_id` in `device_roots` first (CLI device flow) and then
// in `issued_credentials` (service-account credential renewal). The
// signature is over the canonical bytes
// `f"{timestamp}\n{public_key_id}\n{requested_scope}"` (UTF-8). The
// timestamp window is ±60s — replay attempts older than that are
// rejected without touching the keypair lookup.
message KeypairProof {
  // Stable id of the public key. Matches `device_roots.id` or
  // `issued_credentials.id`.
  string public_key_id = 1;
  // Unix-seconds when the signature was produced. Signed input.
  uint64 timestamp = 2;
  // Ed25519 signature over the canonical bytes.
  bytes signature = 3;
}

// Proof that a user-approved device authorization completed. The
// CLI presents the device_code returned by
// `CreateDeviceAuthorization` plus a signature over `device:{code}`
// using the device keypair generated locally. Server completes the
// hand-off: registers the device_root row if not present, mints a
// fresh Biscuit bound to the device pop key.
message DeviceAuthProof {
  string device_code = 1;
  // Raw Ed25519 public-key bytes. Server stores it on first hand-off
  // (`device_roots` row) and signs subsequent renewals against it via
  // `KeypairProof`.
  bytes device_public_key = 2;
  // Signature over the bytes `device:{device_code}` using the
  // device's private key. Replaces the old proof field on
  // `ExchangeDeviceAuthorizationRequest`.
  bytes signature = 3;
}

// Mint a fresh anonymous Biscuit. Unauthenticated; per-IP
// rate-limited server-side (see `docs/auth/anon-biscuit-spike.md`
// §7.1: 10/hour/IP). When `WEFT_MINT_TURNSTILE_REQUIRED=true` on
// the server, `turnstile_token` MUST be a valid Cloudflare
// Turnstile token; otherwise the field is ignored. See
// HeddleCo/weft#189 and HeddleCo/weft#192.
message MintAnonBiscuitRequest {
  // Cloudflare Turnstile token captured by the caller's frontend.
  // Required when the server has `WEFT_MINT_TURNSTILE_REQUIRED=true`
  // (operator-flippable break-glass under coordinated abuse). Empty
  // / omitted when the flag is off.
  optional string turnstile_token = 1;
}

message MintAnonBiscuitResponse {
  // Raw biscuit bytes carrying `subject_kind("anon")` + a fresh
  // `Anon(uuid)` subject. The caller (tapestry's worker) base64-
  // encodes these for the `weft_auth` cookie value. See
  // `docs/auth/anon-biscuit-spike.md` §3.3 for the cookie shape.
  bytes biscuit = 1;
  // Hard expiry the biscuit was minted with. Same value as the
  // biscuit's internal `expires_at` fact; surfaced so callers can
  // set `Max-Age` on the cookie without parsing the token.
  google.protobuf.Timestamp expires_at = 2;
}

message IssuedCredentialResponse {
  string credential_id = 1;
  string token = 2;
  google.protobuf.Timestamp expires_at = 3;
  string subject = 4;
  optional string provider = 5;
  optional string model = 6;
  optional string device_id = 7;
}
message WhoAmIRequest {}
message WhoAmIResponse {
  string subject = 1;
  // True when the bearer carries the operator (staff) marker. This is the
  // Heddle-internal "operator" capability used to gate the intake page,
  // email-template editor, and the global ListActors/ListWorktrees views.
  // Distinct from the namespace-level `Role::Admin` grant that customers
  // hold on their own resources.
  bool is_staff = 2;
  bool is_biscuit = 3;
  string session_id = 4;
  repeated string amr = 5;
  string scope = 6;
  string credential_id = 7;
  optional string device_id = 8;
  optional string agent_provider = 9;
  optional string agent_model = 10;
  string actor_subject = 11;
  bool is_service_account = 12;
  // Resource grants the caller holds directly. Does NOT include inherited
  // roles — those are resolved server-side when enforcing per-RPC checks.
  // Web clients may use `roles` for UI gating but MUST NOT rely on it for
  // authorization decisions.
  repeated ResourceRole roles = 13;
}

// A single role grant held by the caller on a namespace or repository.
message ResourceRole {
  // Visible resource path, e.g. "heddle/core-services" or "heddle".
  string resource_path = 1;
  // "namespace" or "repository".
  string resource_kind = 2;
  // Role name: one of "reader", "developer", "maintainer", "admin", "owner".
  HostedRole role = 3;
}
message IntrospectCredentialRequest {
  string credential_id = 1;
}
message CredentialInfo {
  string credential_id = 1;
  string subject = 2;
  string kind = 3;
  string scope = 4;
  // Epoch seconds. 0 = never expires.
  google.protobuf.Timestamp expires_at = 5;
  bool revoked = 6;
  optional string device_id = 7;
  optional string provider = 8;
  optional string model = 9;
  // Epoch seconds, always populated.
  google.protobuf.Timestamp created_at = 10;
  // Epoch seconds. 0 = never used. Touched on every successful Biscuit validation.
  google.protobuf.Timestamp last_used_at = 11;
}
message ServiceAccountResponse {
  string service_account_id = 1;
  string subject = 2;
  string display_name = 3;
  string scope = 4;
}
message RevokeServiceAccountRequest {
  string service_account_id = 1;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}
message ListServiceAccountsRequest {}
message ListServiceAccountsResponse {
  repeated ServiceAccountWithCredentials service_accounts = 1;
}
message ServiceAccountWithCredentials {
  string service_account_id = 1;
  string subject = 2;
  string display_name = 3;
  string scope = 4;
  repeated CredentialInfo credentials = 5;
  bool revoked = 6;
  // Epoch seconds, always populated.
  google.protobuf.Timestamp created_at = 7;
  // Username of the human who created this service account. Empty when
  // the creator's user row has been deleted (ON DELETE SET NULL).
  string created_by_subject = 8;
}

message ListScopeCapabilitiesRequest {}
message ListScopeCapabilitiesResponse {
  repeated ScopeCapability capabilities = 1;
}

message ScopeCapability {
  // Canonical scope token, e.g. "repo:write", "agent:spawn".
  string name = 1;
  // Short human-readable description for the UI reference grid.
  string description = 2;
  // Coarse category for UI grouping: "repo", "thread", "agent", "grant",
  // "namespace", "presence", "admin".
  string category = 3;
}
message ListNamespacesResponse {
  repeated HostedNamespace namespaces = 1;
}
message ListRepositoriesResponse {
  repeated HostedRepository repositories = 1;
}
message ListGrantsResponse {
  repeated HostedGrant grants = 1;
}

message GetRefsRequest {
  string repo_path = 1;
}

message GetRefsResponse {
  string head = 1;
  repeated RefEntry threads = 2;
  repeated string markers = 3;
}

message ListStatesRequest {
  string repo_path = 1;
  string ref = 2;
  uint32 limit = 3;
}

message GetStateRequest {
  string repo_path = 1;
  string id = 2;
}

message GetBlameRequest {
  string repo_path = 1;
  string ref = 2;
  string path = 3;
}

message ListProvenanceSummariesRequest {
  string repo_path = 1;
  string ref = 2;
  repeated string paths = 3;
}

message GetTreeRequest {
  string repo_path = 1;
  string ref = 2;
  string path = 3;
}

message TreeEntry {
  string name = 1;
  string kind = 2;
  bytes hash = 3;
}

message TreeResponse {
  bytes state_id = 1;
  string path = 2;
  repeated TreeEntry entries = 3;
}

message GetBlobRequest {
  string repo_path = 1;
  string ref = 2;
  string path = 3;
}

message GetCompareRequest {
  string repo_path = 1;
  string from = 2;
  string to = 3;
  bool include_semantic = 4;
}

message BlobResponse {
  string path = 1;
  bytes state_id = 2;
  uint64 size = 3;
  bool is_binary = 4;
  string content = 5;
}

message DiffSpan {
  uint32 start = 1; // byte offset into content
  uint32 end = 2; // byte offset (exclusive)
  bool changed = 3; // true = this span was added/removed
}

message DiffLine {
  string kind = 1;
  string content = 2;
  repeated DiffSpan word_changes = 3;
  optional string moved_counterpart_path = 4;
  optional uint32 moved_counterpart_line = 5;
}

message DiffHunk {
  uint32 old_start = 1;
  uint32 old_lines = 2;
  uint32 new_start = 3;
  uint32 new_lines = 4;
  repeated DiffLine lines = 5;
}

message FileDiff {
  string path = 1;
  string kind = 2;
  repeated DiffHunk hunks = 3;
  // Semantic classification of the modification (e.g. "Logic", "FormattingOnly", "ImportsOnly").
  string classification = 4;
  // Review importance (e.g. "High", "Medium", "Low", "Noise").
  string importance = 5;
}

message DiffResponse {
  bytes state_id = 1;
  bytes parent_id = 2;
  bool has_parent = 3;
  repeated FileDiff files = 4;
}

message CompareSummary {
  uint32 added = 1;
  uint32 modified = 2;
  uint32 deleted = 3;
  uint32 renamed = 4;
  uint32 total = 5;
}

message CompareResponse {
  bytes from_state_id = 1;
  bytes to_state_id = 2;
  repeated FileDiff files = 3;
  repeated string semantic_changes_json = 4;
  CompareSummary summary = 5;
}

// Hot-spot aggregation: walk first-parent history starting from
// `from_state` (or HEAD), aggregate semantic-change events across the
// last `limit_states` pairs, and rank by event count. Pairs with the
// CLI's `heddle semantic hot` and the semantic crate's
// `analyze_hot_spots` function — same primitives, no per-host glue.
message GetSemanticHotSpotsRequest {
  string repo_path = 1;
  // State to start the walk from. Empty = HEAD.
  string from_state = 2;
  // Walk at most this many state pairs. Default 200.
  uint32 limit_states = 3;
  // What to bucket on. "file" or "function" (case-insensitive).
  // Default "file".
  string group_by = 4;
  // Restrict to events of these kinds (e.g. "signature_changed",
  // "function_extracted"). Empty list = no filter. Spelling matches
  // the kebab-case clap value-enum names from the CLI.
  repeated string include_kinds = 5;
  // Substring path filters. A path matches `include_paths` if any
  // entry is a substring of the path; matches `exclude_paths` likewise.
  repeated string include_paths = 6;
  repeated string exclude_paths = 7;
  // Number of slots to return at the top. Default 20.
  uint32 top_n = 8;
  // If true, populate per-actor histograms on each spot.
  bool include_actors = 9;
}

message GetSemanticHotSpotsResponse {
  repeated SemanticHotSpot spots = 1;
  uint32 states_walked = 2;
  uint32 total_events = 3;
}

message SemanticHotSpot {
  // "file" or "function".
  string key_kind = 1;
  string path = 2;
  // Empty unless `key_kind == "function"`.
  string function = 3;
  uint32 event_count = 4;
  uint32 state_count = 5;
  string first_seen = 6;
  string last_seen = 7;
  // Histogram of event kinds (e.g. {"signature_changed": 3,
  // "function_extracted": 2}). Sums to `event_count`.
  map<string, uint32> by_kind = 8;
  // Histogram of actors (e.g. {"alice <a@x>": 4}). Empty unless the
  // request set `include_actors = true`.
  map<string, uint32> by_actor = 9;
}

message ActionSummary {
  string id = 1;
  optional string from_state = 2;
  string to_state = 3;
  string operation_json = 4;
  string description = 5;
  repeated string semantic_changes_json = 6;
  string timestamp = 7;
}

message ActionsResponse {
  repeated ActionSummary actions = 1;
}

message StatePrincipal {
  string name = 1;
  string email = 2;
}

message StateAgent {
  string provider = 1;
  string model = 2;
}

message StateVerification {
  uint32 tests_passed = 1;
  uint32 tests_failed = 2;
  optional float coverage_pct = 3;
  optional float coverage_delta = 4;
  uint32 lint_warnings = 5;
}

message StateSummary {
  bytes change_id = 1;
  repeated bytes parents = 2;
  string intent = 3;
  google.protobuf.Timestamp created_at = 4;
  StatePrincipal principal = 5;
  optional StateAgent agent = 6;
  optional float confidence = 7;
  StateStatus status = 8;
  optional StateVerification verification = 9;
  bytes logical_change_id = 10;
}

message ListStatesResponse {
  repeated StateSummary states = 1;
}

message BlameOrigin {
  bytes change_id = 1;
  string author = 2;
  string timestamp = 3;
  string principal_name = 4;
  string principal_email = 5;
  string agent_provider = 6;
  string agent_model = 7;
}

message BlameLine {
  uint32 line_number = 1;
  string content = 2;
  bytes change_id = 3;
  string author = 4;
  string timestamp = 5;
  repeated BlameOrigin origins = 6;
  bool carried = 7;
}

message BlameSummary {
  uint32 total_lines = 1;
  uint32 distinct_origin_count = 2;
  uint32 multi_origin_line_count = 3;
  uint32 carried_line_count = 4;
}

message GetBlameResponse {
  string path = 1;
  bytes state_id = 2;
  bool has_provenance = 3;
  repeated BlameLine lines = 4;
  BlameSummary summary = 5;
}

enum ProvenanceFileStatus {
  PROVENANCE_FILE_STATUS_UNSPECIFIED = 0;
  PROVENANCE_FILE_STATUS_AVAILABLE = 1;
  PROVENANCE_FILE_STATUS_PROVENANCE_ABSENT = 2;
  PROVENANCE_FILE_STATUS_BINARY = 3;
  PROVENANCE_FILE_STATUS_FILE_NOT_FOUND = 4;
  PROVENANCE_FILE_STATUS_ERROR = 5;
  PROVENANCE_FILE_STATUS_PENDING = 6;
}

enum DerivedSummaryBatchStatus {
  DERIVED_SUMMARY_BATCH_STATUS_UNSPECIFIED = 0;
  DERIVED_SUMMARY_BATCH_STATUS_READY = 1;
  DERIVED_SUMMARY_BATCH_STATUS_PENDING = 2;
  DERIVED_SUMMARY_BATCH_STATUS_RUNNING = 3;
  DERIVED_SUMMARY_BATCH_STATUS_FAILED = 4;
}

message ProvenanceFileSummary {
  string path = 1;
  ProvenanceFileStatus status = 2;
  optional BlameSummary summary = 3;
  uint32 authored_line_count = 4;
}

message ListProvenanceSummariesResponse {
  bytes state_id = 1;
  repeated ProvenanceFileSummary files = 2;
  string job_id = 3;
  DerivedSummaryBatchStatus batch_status = 4;
  string batch_label = 5;
}

// ── Context / Annotation messages ────────────────────────

enum StalenessStatus {
  STALENESS_UNKNOWN = 0;
  STALENESS_FRESH = 1;
  STALENESS_SOURCE_CHANGED = 2;
  STALENESS_SYMBOL_MISSING = 3;
}

message SymbolScope {
  string name = 1;
  optional uint32 resolved_start = 2;
  optional uint32 resolved_end = 3;
}

message LineRange {
  uint32 start = 1;
  uint32 end = 2;
}

message AnnotationScope {
  oneof scope {
    bool file = 1;
    SymbolScope symbol = 2;
    LineRange lines = 3;
  }
}

message ContextAnnotation {
  string id = 1;
  AnnotationScope scope = 2;
  string content = 3;
  repeated string tags = 4;
  string attribution = 5;
  google.protobuf.Timestamp created_at = 6;
  optional bytes source_hash = 7;
  optional string created_at_state = 8;
  StalenessStatus staleness = 9;
  ContextAnnotationStatus status = 10;
  ContextAnnotationKind kind = 11;
  uint32 revision_count = 12;
  optional string supersedes_annotation_id = 13;
  optional uint32 supersedes_rewrite_pct = 14;
}

message AnnotatedFile {
  string path = 1;
  repeated ContextAnnotation annotations = 2;
}

message StateContextEntry {
  bytes state_id = 1;
  repeated ContextAnnotation annotations = 2;
}

message ListContextRequest {
  string repo_path = 1;
  string ref = 2;
  optional string prefix = 3;
  optional string tag_filter = 4;
}

message ListContextResponse {
  repeated AnnotatedFile files = 1;
  ContextSummary summary = 2;
  repeated StateContextEntry states = 3;
}

message ContextRevision {
  string revision_id = 1;
  ContextAnnotationKind kind = 2;
  string content = 3;
  repeated string tags = 4;
  string attribution = 5;
  google.protobuf.Timestamp created_at = 6;
  optional bytes source_hash = 7;
  optional string created_at_state = 8;
}

message GetContextHistoryRequest {
  string repo_path = 1;
  string ref = 2;
  string annotation_id = 3;
}

message GetContextHistoryResponse {
  ContextAnnotation annotation = 1;
  repeated ContextRevision revisions = 2;
  optional string target_path = 3;
  optional bytes target_state_id = 4;
}

message SetContextRequest {
  string repo_path = 1;
  string path = 2;
  AnnotationScope scope = 3;
  repeated string tags = 4;
  string content = 5;
  string agent_provider = 6;
  string agent_model = 7;
  optional bytes target_state_id = 8;
  ContextAnnotationKind kind = 9;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message SetContextResponse {
  // Newly-captured ChangeId carrying the annotation.
  bytes state = 1;
  uint32 annotations = 2;
}

message ReviseContextRequest {
  string repo_path = 1;
  string annotation_id = 2;
  string content = 3;
  repeated string tags = 4;
  string agent_provider = 5;
  string agent_model = 6;
  ContextAnnotationKind kind = 7;
}

message ReviseContextResponse {
  // Newly-captured ChangeId carrying the revision.
  bytes state = 1;
  uint32 revisions = 2;
}

message SupersedeContextRequest {
  string repo_path = 1;
  string annotation_id = 2;
  string path = 3;
  AnnotationScope scope = 4;
  repeated string tags = 5;
  string content = 6;
  string agent_provider = 7;
  string agent_model = 8;
  optional bytes target_state_id = 9;
  ContextAnnotationKind kind = 10;
}

message SupersedeContextResponse {
  // Newly-captured ChangeId carrying the supersession.
  bytes state = 1;
  string new_annotation_id = 2;
  uint32 rewrite_pct = 3;
}

message ContextSummary {
  uint32 total_annotations = 1;
  uint32 file_count = 2;
  uint32 stale_count = 3;
  uint32 invariant_count = 4;
  uint32 rationale_count = 5;
  uint32 constraint_count = 6;
  uint32 state_guidance_count = 7;
  uint32 superseded_count = 8;
  uint32 major_rewrite_count = 9;
}

enum ContextAnnotationStatus {
  CONTEXT_ANNOTATION_STATUS_UNSPECIFIED = 0;
  CONTEXT_ANNOTATION_STATUS_ACTIVE = 1;
  CONTEXT_ANNOTATION_STATUS_SUPERSEDED = 2;
}

enum ContextAnnotationKind {
  CONTEXT_ANNOTATION_KIND_UNSPECIFIED = 0;
  CONTEXT_ANNOTATION_KIND_CONSTRAINT = 1;
  CONTEXT_ANNOTATION_KIND_INVARIANT = 2;
  CONTEXT_ANNOTATION_KIND_RATIONALE = 3;
}

enum ContextSuggestionTier {
  CONTEXT_SUGGESTION_TIER_UNSPECIFIED = 0;
  CONTEXT_SUGGESTION_TIER_MEDIUM = 1;
  CONTEXT_SUGGESTION_TIER_HIGH = 2;
}

message ContextSuggestion {
  string path = 1;
  uint32 score = 2;
  ContextSuggestionTier tier = 3;
  repeated string reasons = 4;
  uint32 recent_changes = 5;
  uint32 distinct_states = 6;
  uint32 distinct_agents = 7;
  bool has_context = 8;
  uint32 stale_annotations = 9;
}

message ListContextSuggestionsRequest {
  string repo_path = 1;
  string ref = 2;
  uint32 limit = 3;
}

message ListContextSuggestionsResponse {
  repeated ContextSuggestion suggestions = 1;
}

message SubscribeRepoEventsRequest {
  string repo_id = 1;
  string thread = 2;
  int64 after_event_id = 3;
  repeated string event_types = 4;
}

message RepoEvent {
  int64 event_id = 1;
  string repo_id = 2;
  string event_type = 3;
  string thread = 4;
  string ref_name = 5;
  bool is_thread = 6;
  string old_state = 7;
  string new_state = 8;
  string actor_subject = 9;
  string payload_json = 10;
  google.protobuf.Timestamp created_at = 11;
}

message ThreadSummary {
  string name = 1;
  optional bytes base_state = 2;
  optional bytes current_state = 3;
  optional string path = 4;
  optional string provider = 5;
  optional string model = 6;
  string coordination_status = 7;
  bool is_current = 8;
  bool is_isolated = 9;
  optional string target_thread = 10;
  optional string parent_thread = 11;
  optional string task = 12;
  optional string thread_mode = 13;
  optional string thread_state = 14;
  optional string freshness = 15;
  ThreadVerificationSummary verification_summary = 16;
  ThreadConfidenceSummary confidence_summary = 17;
  ThreadIntegrationPolicy integration_policy_result = 18;
  optional string harness = 19;
  optional string thinking_level = 20;
  UsageSummary usage_summary = 21;
  optional google.protobuf.Timestamp last_progress_at = 22;
  optional string report_flush_state = 23;
  optional string heddle_session_id = 24;
  optional string attach_reason = 25;
  optional string native_actor_key = 26;
  optional string native_parent_actor_key = 27;
  optional string probe_source = 28;
  optional float probe_confidence = 29;
  optional string recommended_action = 30;
  repeated string blockers = 31;
  optional string thread_health = 32;
  bool promotion_suggested = 33;
  repeated string child_threads = 34;
  uint32 stack_depth = 35;
  optional google.protobuf.Timestamp last_activity_at = 36;
  optional string visibility = 37;
  repeated string changed_paths = 38;
  repeated string impact_categories = 39;
  repeated string heavy_impact_paths = 40;
}

message ThreadVerificationSummary {
  optional bool tests_passed = 1;
  uint32 tests_failed = 2;
  optional float coverage_pct = 3;
  optional uint32 lint_warnings = 4;
}

message ThreadConfidenceSummary {
  optional float value = 1;
  optional string band = 2;
}

message UsageSummary {
  optional uint64 input_tokens = 1;
  optional uint64 output_tokens = 2;
  optional uint64 reasoning_tokens = 3;
  optional uint64 tool_calls = 4;
  optional uint64 cost_micros_usd = 5;
}

message ThreadIntegrationPolicy {
  string status = 1;
  string reason = 2;
}

message ThreadMetadata {
  string name = 1;
  optional string target_thread = 2;
  optional string parent_thread = 3;
  optional string task = 4;
  string thread_mode = 5;
  string thread_state = 6;
  string freshness = 7;
  bytes base_state = 8;
  bytes base_root = 9;
  optional bytes current_state = 10;
  optional bytes merged_state = 11;
  repeated string changed_paths = 12;
  repeated string impact_categories = 13;
  repeated string heavy_impact_paths = 14;
  bool promotion_suggested = 15;
  ThreadVerificationSummary verification_summary = 16;
  ThreadConfidenceSummary confidence_summary = 17;
  ThreadIntegrationPolicy integration_policy_result = 18;
  google.protobuf.Timestamp created_at = 19;
  google.protobuf.Timestamp updated_at = 20;
}

message StartThreadRequest {
  string repo_path = 1;
  string name = 2;
  string from_state = 3;
  string path = 4;
  string provider = 5;
  string model = 6;
}

message ListThreadsRequest {
  string repo_path = 1;
}

message ListThreadsResponse {
  repeated ThreadSummary threads = 1;
}

message GetWorkspaceSummaryRequest {
  string repo_path = 1;
}

message WorkspaceThreadGroup {
  string id = 1;
  string label = 2;
  repeated ThreadSummary threads = 3;
}

message WorkspaceSummary {
  string repo_path = 1;
  optional string current_thread = 2;
  repeated WorkspaceThreadGroup groups = 3;
  uint32 thread_count = 4;
}

message GetThreadRequest {
  string repo_path = 1;
  string name = 2;
}

message CaptureThreadRequest {
  string repo_path = 1;
  string name = 2;
  string intent = 3;
  optional float confidence = 4;
}

message CaptureThreadResponse {
  string thread = 1;
  bytes change_id = 2;
  string coordination_status = 3;
}

message CompleteThreadRequest {
  string repo_path = 1;
  string name = 2;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message WatchThreadsRequest {
  string repo_path = 1;
  int64 after_event_id = 2;
}

message ThreadEvent {
  int64 event_id = 1;
  string thread = 2;
  string event_type = 3;
  ThreadSummary summary = 4;
  google.protobuf.Timestamp created_at = 5;
}

// ── OAuth identity linking ──────────────────────────────────────────────────

message LinkOAuthIdentityRequest {
  // Which OAuth provider this proof is from. Used by the server to
  // pick the correct ID-token verifier (JWKS for Google/Apple,
  // /user + /user/emails API path for GitHub).
  string provider = 1; // "google" | "apple" | "github"

  // The provider's cryptographic proof of identity.
  //   - Google / Apple: OIDC `id_token` (provider-signed JWT).
  //   - GitHub: OAuth2 access token (server validates via /user +
  //     /user/emails).
  // Server validates this proof and extracts
  // `(sub, email, email_verified)` from the verified result; the
  // request itself carries NO user-identity claims the caller can
  // forge. Closes the OAuth-link forge bypass surfaced on
  // HeddleCo/weft#193 r2 (Codex cid 3294026240); design rationale
  // in the HeddleCo/weft#195 spike amendment.
  oneof oauth_proof {
    string id_token = 2;
    string access_token = 3;
  }

  // OPTIONAL: a fresh WebAuthn assertion proving ownership of the
  // existing user account that this OAuth identity is being
  // attached to. Required only when the OAuth-validated email
  // collides with an existing user (per the email-collision passkey
  // gate, weft spike §4.6); otherwise empty.
  optional bytes webauthn_assertion = 4;
}

message StoreProviderTokenRequest {
  string provider = 1;
  string access_token = 2;
  string refresh_token = 3;
  string token_scope = 4;
  google.protobuf.Timestamp expires_at = 5;
}

message StoreProviderTokenResponse {}

// ── External diff analysis (for PR review packets) ──────────────────────────

message AnalyzeExternalDiffRequest {
  repeated ExternalFilePair files = 1;
}

message ExternalFilePair {
  string path = 1;
  bytes old_content = 2;
  bytes new_content = 3;
}

message ExternalSemanticChange {
  string change_type = 1; // "logic" | "api" | "schema" | "config" | "test" | "formatting" | "rename" | "import"
  string description = 2;
  string path = 3;
  int32 additions = 4;
  int32 deletions = 5;
}

message AnalyzeExternalDiffResponse {
  int32 files_changed = 1;
  int32 semantic_changes = 2;
  int32 noise_filtered = 3;
  repeated ExternalSemanticChange changes = 4;
}

// Public invitation-summary lookup. Never throws — unknown/malformed
// ids return `exists: false, valid: false`.
message GetInvitationSummaryRequest {
  string id = 1;
}

message InvitationSummary {
  bool exists = 1;
  bool valid = 2; // false if revoked, accepted, or expired
  string namespace_path = 3;
  string namespace_name = 4; // may be empty if metadata has no display name
  HostedRole role = 5;
  // Intentionally NO email or invited_by — don't leak PII.
}

// ───────────────────────────────────────────────────────────────
// FeedService — Mission Control Stream (V1: review readiness)
// ───────────────────────────────────────────────────────────────

service FeedService {
  // Server-streaming: delivers priority-ranked feed items in real-time.
  // Client opens one stream after initial snapshot load.
  rpc StreamFeed(StreamFeedRequest) returns (stream FeedItem);

  // Unary: initial page load. Returns current feed state.
  rpc GetFeedSnapshot(GetFeedSnapshotRequest) returns (FeedSnapshot);

  // Unary: record a user interaction for ranking personalization.
  // Fire-and-forget from the client perspective.
  rpc RecordInteraction(RecordInteractionRequest) returns (RecordInteractionResponse);

  // Unary: dismiss, pin, or act on a feed item.
  rpc UpdateFeedItem(UpdateFeedItemRequest) returns (UpdateFeedItemResponse);
}

// --- Feed Requests ---

message StreamFeedRequest {
  // Optional: only stream items for specific repos
  repeated string repo_paths = 1;
  // Optional: only stream specific kinds
  repeated FeedItemKind kinds = 2;
  // Resume token: the item_id of the last received FeedItem.
  // On reconnect, server queries feed_items WHERE id > resume_after_item_id
  // to catch up, then switches to live streaming. Format: numeric string (feed_items.id).
  // Empty/omitted: stream from "now" (only new items, no catch-up).
  string resume_after_item_id = 3;
}

message GetFeedSnapshotRequest {
  // Max items to return (default 50). V1: capped at 50 with no pagination.
  uint32 limit = 1;
  // Filter by resolution status (default: PENDING only)
  repeated FeedItemResolution resolution_filter = 2;
  // Optional repo filter
  repeated string repo_paths = 3;
}

message RecordInteractionRequest {
  // Subject (user) is resolved server-side from the authenticated Biscuit claim.
  InteractionKind kind = 1;
  string resource_path = 2; // e.g. "luke/objects/-/changes/abc123"
  string feed_item_id = 3; // optional: the feed item that led to this interaction
}

message UpdateFeedItemRequest {
  string item_id = 1;
  FeedItemResolution resolution = 2; // dismiss, pin, mark acted_on
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

// --- Feed Responses ---

message FeedSnapshot {
  repeated FeedItem items = 1;
  uint32 total_pending = 2; // total pending items (may exceed returned limit)
  string resume_token = 3; // item_id of last item; pass to StreamFeed.resume_after_item_id
}

message RecordInteractionResponse {}

message UpdateFeedItemResponse {
  FeedItem item = 1;
}

// --- Feed Core Types ---

message FeedItem {
  string item_id = 1;
  FeedItemKind kind = 2;
  uint32 priority_score = 3; // 0-1000, server-computed
  string headline = 4; // "objects: 3 files changed, 2 breaking API changes"
  string detail = 5; // truncated diff summary, pre-populated by materializer
  string repo_path = 6;
  string resource_id = 7; // change_id, session_id, etc.
  FeedItemResolution resolution = 8;
  float confidence = 9; // 0.0-1.0, for future auto-resolution
  repeated FeedItemAction actions = 10;
  google.protobuf.Timestamp created_at = 11;
  google.protobuf.Timestamp expires_at = 12;
  // Populated by FeedMaterializer in V1 (used in priority scoring and card metadata):
  string actor_subject = 13; // who/what caused this item
  bool has_agent_reasoning = 14; // context annotations attached? (+25 priority boost)
  uint32 files_changed = 15; // count from diff (feeds card metadata line)
  uint32 breaking_changes = 16; // count from semantic diff (+200 priority boost)
  string top_annotation = 17; // most important WHY/GOTCHA annotation text (max 80 chars)
  bool confidence_computed = 18; // false in V1; true in V2 when confidence is set
  bool security_sensitive = 19; // stored for score reproducibility
  float affinity_multiplier = 20; // stored for score reproducibility (default 1.0)
  repeated FileChangeSummary top_files = 21; // top changed file paths (max 5)
  repeated BreakingChangeSummary breaking_summaries = 22; // top breaking semantic changes (max 3)
}

message FileChangeSummary {
  string path = 1;
  FileChangeKind kind = 2;
}

enum FileChangeKind {
  FILE_CHANGE_KIND_UNSPECIFIED = 0;
  FILE_CHANGE_KIND_ADDED = 1;
  FILE_CHANGE_KIND_MODIFIED = 2;
  FILE_CHANGE_KIND_DELETED = 3;
}

message BreakingChangeSummary {
  string kind = 1; // e.g. "function_deleted", "dependency_removed"
  string path = 2; // file path where the break was detected
  string symbol = 3; // symbol/name involved (function, dependency, etc.)
}

message FeedItemAction {
  string action_id = 1; // "review", "dismiss", "pin", "acted_on"
  string label = 2; // "Review", "Dismiss", "Pin"
  string url = 3; // deep link into the app
  bool is_primary = 4;
}

enum FeedItemKind {
  FEED_ITEM_KIND_UNSPECIFIED = 0;
  FEED_ITEM_KIND_REVIEW_READY = 1; // V1: thread/change ready for review
  FEED_ITEM_KIND_AGENT_COMPLETED = 2; // V2: agent session finished
  FEED_ITEM_KIND_SECURITY_SURFACE = 3; // V2: stale token, over-scoped grant
  FEED_ITEM_KIND_DRIFT_ALERT = 4; // V2: thread diverged significantly
  FEED_ITEM_KIND_REPO_QUIET = 5; // V2: repo inactive with open threads
}

enum FeedItemResolution {
  FEED_ITEM_RESOLUTION_UNSPECIFIED = 0;
  FEED_ITEM_RESOLUTION_PENDING = 1;
  FEED_ITEM_RESOLUTION_DISMISSED = 2;
  FEED_ITEM_RESOLUTION_ACTED_ON = 3;
  FEED_ITEM_RESOLUTION_AUTO_RESOLVED = 4; // V2: AI handled it
  FEED_ITEM_RESOLUTION_EXPIRED = 5;
  FEED_ITEM_RESOLUTION_PINNED = 6;
}

enum InteractionKind {
  INTERACTION_KIND_UNSPECIFIED = 0;
  INTERACTION_KIND_VIEWED_REPO = 1;
  INTERACTION_KIND_OPENED_REVIEW = 2;
  INTERACTION_KIND_COMPLETED_REVIEW = 3;
  INTERACTION_KIND_DISMISSED_ITEM = 4;
  INTERACTION_KIND_EXPANDED_SESSION = 5;
  INTERACTION_KIND_VIEWED_DIFF = 6;
  INTERACTION_KIND_SEARCHED = 7; // V2+: requires stream search feature
}

// ── Approval groups ─────────────────────────────────────────────────
//
// A reusable approver pool. Membership is computed lazily through the
// grants graph (anyone with `required_role` on `namespace_path` is in
// the group); these messages just hold the rules.

message ApprovalGroup {
  string id = 1; // UUID
  string repo_path = 2;
  string name = 3; // unique within (repo_path, name)
  string description = 4;
  google.protobuf.Timestamp created_at = 5; // unix seconds
}

message ApprovalGroupMember {
  string id = 1;
  string group_id = 2;
  string namespace_path = 3;
  // 'reader' | 'developer' | 'maintainer' | 'admin' | 'owner'
  HostedRole required_role = 4;
}

message CreateApprovalGroupRequest {
  string repo_path = 1;
  string name = 2;
  string description = 3;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message ListApprovalGroupsRequest {
  string repo_path = 1;
}

message ListApprovalGroupsResponse {
  repeated ApprovalGroup groups = 1;
  // Members keyed by group id, surfaced inline so the UI doesn't
  // have to N+1 query.
  map<string, ApprovalGroupMembers> members_by_group = 2;
}

message ApprovalGroupMembers {
  repeated ApprovalGroupMember members = 1;
}

message DeleteApprovalGroupRequest {
  string id = 1;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message AddApprovalGroupMemberRequest {
  string group_id = 1;
  string namespace_path = 2;
  HostedRole required_role = 3;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message RemoveApprovalGroupMemberRequest {
  string id = 1;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

// ── Thread policies (branch protection) ─────────────────────────────

message ThreadPolicy {
  string id = 1;
  string repo_path = 2;
  // Glob pattern: "main", "release/*", "*", etc.
  string target_thread_pattern = 3;
  // 'merge' | 'force_push' | 'complete'
  string gated_action = 4;
  // Flat-role requirement. `required_role` is one of
  // 'reader' | 'developer' | 'maintainer' | 'admin' | 'owner'.
  // When `required_approvals` == 0, the flat-role requirement is
  // skipped (only group-based requirements apply).
  uint32 required_approvals = 5;
  HostedRole required_role = 6;
  // CODEOWNERS-style: policy fires only when the diff intersects
  // one of these globs. Empty = applies to every change.
  repeated string required_paths = 7;
  // Approver may not be the author of any state on the source thread.
  // Only enforceable when the merge call passes `author_user_id`.
  bool forbid_author_approval = 8;
  bool stale_on_update = 9;
  // Wall-clock approval expiry; 0 = no expiry.
  google.protobuf.Duration approval_ttl_secs = 10;
  google.protobuf.Timestamp created_at = 11;
  google.protobuf.Timestamp updated_at = 12;
}

message PolicyGroupRequirement {
  string id = 1;
  string policy_id = 2;
  string group_id = 3;
  uint32 required_approvals = 4;
  // Conditional trigger: requirement applies only when diff
  // intersects one of these globs. Empty = always applies.
  repeated string only_if_paths_match = 5;
}

message CreateThreadPolicyRequest {
  string repo_path = 1;
  string target_thread_pattern = 2;
  string gated_action = 3;
  uint32 required_approvals = 4; // 0 = skip flat-role check
  HostedRole required_role = 5;
  repeated string required_paths = 6;
  bool forbid_author_approval = 7;
  bool stale_on_update = 8;
  google.protobuf.Duration approval_ttl_secs = 9; // 0 = no expiry
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message ListThreadPoliciesRequest {
  string repo_path = 1;
}

message ListThreadPoliciesResponse {
  repeated ThreadPolicy policies = 1;
  // Group requirements keyed by policy id.
  map<string, PolicyGroupRequirements> requirements_by_policy = 2;
}

message PolicyGroupRequirements {
  repeated PolicyGroupRequirement requirements = 1;
}

message DeleteThreadPolicyRequest {
  string id = 1;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message AddPolicyGroupRequirementRequest {
  string policy_id = 1;
  string group_id = 2;
  uint32 required_approvals = 3;
  repeated string only_if_paths_match = 4;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message RemovePolicyGroupRequirementRequest {
  string id = 1;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

// ── Thread merge approvals ──────────────────────────────────────────
//
// An approval is a record that a particular user signed off on a
// particular (source_thread → target_thread) merge at a specific
// `source_state` (the change_id at the source's head). The merge
// gate consumes these rows and matches them against every policy
// whose target glob matches `target_thread`.

message ThreadApproval {
  string id = 1;
  string repo_path = 2;
  string source_thread = 3;
  string target_thread = 4;
  // Change id at source thread's head when this approval was
  // recorded. Used for stale-on-update detection.
  bytes source_state = 5;
  string approver_user_id = 6;
  // Biscuit session id of the approver — full audit attribution.
  string approver_sid = 7;
  string note = 8;
  google.protobuf.Timestamp approved_at = 9;
  // 0 when the policy did not specify a TTL.
  google.protobuf.Timestamp expires_at = 10;
}

message ApproveThreadRequest {
  string repo_path = 1;
  string source_thread = 2;
  string target_thread = 3;
  bytes source_state = 4;
  string note = 5;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message RevokeApprovalRequest {
  string id = 1;
  // Idempotency (audit-idempotency enforces tag 15).
  string client_operation_id = 15;
}

message ListThreadApprovalsRequest {
  string repo_path = 1;
  string source_thread = 2;
  string target_thread = 3;
}

message ListThreadApprovalsResponse {
  repeated ThreadApproval approvals = 1;
}

// Eligibility check: caller asks "can <source> merge into <target>
// at <source_state>?" given the file paths the diff touches. The
// server runs every matching policy and reports satisfied vs unmet
// requirements without performing the merge.

message CheckMergeEligibilityRequest {
  string repo_path = 1;
  string source_thread = 2;
  string target_thread = 3;
  bytes source_state = 4;
  // 'merge' | 'force_push' | 'complete'. Defaults to 'merge' when empty.
  string gated_action = 5;
  // Paths touched by the source thread relative to the diff against
  // target. Empty list = "we don't know" — every path-conditional
  // requirement is treated as triggered (fail-closed).
  repeated string changed_paths = 6;
  // Optional: the user_id of the change author. Used for
  // forbid_author_approval. Empty = unknown / not enforced.
  string author_user_id = 7;
}

message CheckMergeEligibilityResponse {
  bool allowed = 1;
  // Each unmet requirement, with a human-readable reason. Empty
  // when `allowed = true`.
  repeated UnmetRequirement unmet = 2;
  // Approvals the gate considered valid (matching change_id, not
  // expired, not self-approval when forbidden, etc.).
  repeated ThreadApproval valid_approvals = 3;
}

message UnmetRequirement {
  // Policy id this requirement came from.
  string policy_id = 1;
  // 'flat_role' | 'group' — which kind of requirement is unmet.
  string kind = 2;
  // For 'group' requirements, the group id; empty otherwise.
  string group_id = 3;
  // Human-readable explanation: "needs 2 maintainer approvals on
  // org/acme/heddle/threads/main; have 1".
  string reason = 4;
  uint32 needed = 5;
  uint32 have = 6;
}

// =====================================================================
// Foundation services for the Review and Agent-First CLI epics.
//
// Idempotency contract: every state-changing RPC defined below carries
// `string client_operation_id = 100` (UUID v4 string, optional). When
// the server sees the same id twice with the same request body it
// returns the original outcome bit-identical; the same id with a
// different body returns FAILED_PRECONDITION. New state-changing RPCs
// added to existing services should reserve tag 100 for the same field
// going forward — the value of 100 is grep-able and leaves room for
// pre-existing fields below it.
// =====================================================================

// State review surface. Distinct from the GitHub-PR-focused ReviewService
// above. Ships the review payload that powers both the CLI's
// `heddle review show` and the web's state-review route.
service StateReviewService {
  rpc GetReviewPayload(GetReviewPayloadRequest) returns (ReviewPayload);
  rpc SignState(SignStateRequest) returns (SignStateResponse);
  rpc ListSignatures(ListSignaturesRequest) returns (ListSignaturesResponse);
}

message GetReviewPayloadRequest {
  string repo_path = 1;
  bytes state_id = 2;
  // When true, the response includes `all_signals` populated.
  bool include_all_signals = 3;
}

message ReviewPayload {
  bytes state_id = 1;
  ReviewSummary summary = 2;
  // Agent-authored narrative captured at snapshot time. Empty when
  // the state has no agent attribution.
  string agent_narrative = 3;
  ReadingOrderPartition partition = 4;
  repeated RiskSignal in_budget_signals = 5;
  // Full set of fired signals; only populated when the request set
  // `include_all_signals = true`.
  repeated RiskSignal all_signals = 6;
  uint32 tick_budget = 7;
  repeated AnchoredDiscussion discussions = 8;
  repeated MergeRequirement merge_requirements = 9;
  SigningFooter signing_footer = 10;
}

message ReviewSummary {
  string headline = 1;
  uint32 files_changed = 2;
  uint32 added_lines = 3;
  uint32 removed_lines = 4;
  uint32 in_budget_signal_count = 5;
  uint32 hidden_signal_count = 6;
}

message ReadingOrderPartition {
  repeated PathSymbolRef structural = 1;
  repeated PathSymbolRef consequence = 2;
  repeated PathSymbolRef tests_and_docs = 3;
}

message PathSymbolRef {
  string file = 1;
  string symbol = 2;
}

message RiskSignal {
  // Snake-case wire vocabulary matching `objects::object::RiskSignalKind`.
  string kind = 1;
  SignalAnchor anchor = 2;
  string reason = 3;
  string producer_module = 4;
  uint32 producer_version = 5;
  google.protobuf.Timestamp computed_at = 6;
  // Render-time tier — `visible` or `hidden`. Set by the server-side
  // budgeter so clients render without re-budgeting.
  string visibility = 7;
}

message SignalAnchor {
  string file = 1;
  string symbol = 2;
  // Empty when the signal is file-level rather than symbol-level.
  uint32 start_line = 3;
  uint32 end_line = 4;
}

message AnchoredDiscussion {
  string id = 1;
  PathSymbolRef anchor = 2;
  bytes opened_against_state = 3;
  google.protobuf.Timestamp opened_at = 4;
  repeated DiscussionTurn turns = 5;
  DiscussionResolution resolution = 6;
  bool body_changed_since_open = 7;
  bool orphaned = 8;
  string visibility = 9;
}

message DiscussionTurn {
  string author_name = 1;
  string author_email = 2;
  string body = 3;
  google.protobuf.Timestamp posted_at = 4;
}

// Resolution state of a discussion. `Open` means unresolved; the
// other three variants carry the artifact that closed it. Discussions
// progress strictly Open → resolved/dismissed; the oneof guarantees
// exactly one shape on the wire.
message DiscussionResolution {
  oneof state {
    Open open = 1;
    ResolvedIntoAnnotation into_annotation = 2;
    ResolvedByEdit by_edit = 3;
    Dismissed dismissed = 4;
  }

  message Open {}
  message ResolvedIntoAnnotation {
    string annotation_id = 1;
  }
  message ResolvedByEdit {
    bytes state_id = 1;
  }
  message Dismissed {
    string reason = 1;
  }
}

message MergeRequirement {
  string description = 1;
  bool satisfied = 2;
}

message SigningFooter {
  // Permitted review kinds for this state — server filters out kinds
  // that don't apply (e.g. AGENT_CO_REVIEW hidden when caller isn't
  // an agent).
  repeated ReviewKind available_kinds = 1;
}

message SignStateRequest {
  string repo_path = 1;
  bytes state_id = 2;
  ReviewKind kind = 3;
  ReviewScope scope = 4;
  string justification = 5;
  string algorithm = 6;
  bytes public_key = 7;
  // Raw signature bytes over the canonical signing payload.
  bytes signature = 8;
  // Unix timestamp (seconds) the client signed at. The server rejects values
  // skewed more than `SIGN_TIMESTAMP_SKEW_SECS` from its own clock so the
  // signature can be reproduced and verified deterministically.
  google.protobuf.Timestamp signed_at = 9;
  string client_operation_id = 15;
}

// Scope of a review signature — either the whole change or a list of
// individual symbols. The oneof guarantees exactly one shape on the
// wire.
message ReviewScope {
  oneof scope {
    WholeChange whole_change = 1;
    SymbolList symbols = 2;
  }
  message WholeChange {}
  message SymbolList {
    repeated PathSymbolRef symbols = 1;
  }
}

message SignStateResponse {
  string signature_id = 1;
  bytes state_id = 2;
}

message ListSignaturesRequest {
  string repo_path = 1;
  bytes state_id = 2;
}

message ListSignaturesResponse {
  repeated ReviewSignature signatures = 1;
}

message ReviewSignature {
  string signature_id = 1;
  string actor_name = 2;
  string actor_email = 3;
  ReviewKind kind = 4;
  ReviewScope scope = 5;
  string justification = 6;
  google.protobuf.Timestamp signed_at = 7;
  string algorithm = 8;
  bytes public_key = 9;
  bytes signature = 10;
}

// Discussion CRUD over the per-state `Discussion` blob.
service DiscussionService {
  rpc OpenDiscussion(OpenDiscussionRequest) returns (Discussion);
  rpc AppendTurn(AppendTurnRequest) returns (Discussion);
  rpc ResolveDiscussion(ResolveDiscussionRequest) returns (Discussion);
  rpc ListByState(ListDiscussionsByStateRequest) returns (ListDiscussionsResponse);
  rpc ListBySymbol(ListDiscussionsBySymbolRequest) returns (ListDiscussionsResponse);
  rpc GetDiscussion(GetDiscussionRequest) returns (Discussion);
}

message OpenDiscussionRequest {
  string repo_path = 1;
  bytes state_id = 2;
  PathSymbolRef anchor = 3;
  string body = 4;
  // Optional override; server defaults to the namespace policy.
  string visibility = 5;
  string thread_ref = 6;
  string client_operation_id = 15;
}

message AppendTurnRequest {
  string repo_path = 1;
  string discussion_id = 2;
  string body = 3;
  string client_operation_id = 15;
}

message ResolveDiscussionRequest {
  string repo_path = 1;
  string discussion_id = 2;
  // The resolution mode is a strict oneof — exactly one of the three
  // shapes lands per request, and each shape carries the fields that
  // are meaningful for it.
  oneof resolution {
    ResolveIntoAnnotation into_annotation = 3;
    ResolveByEdit by_edit = 4;
    ResolveDismissed dismissed = 5;
  }
  string client_operation_id = 15;

  message ResolveIntoAnnotation {
    ContextAnnotationKind kind = 1;
    string content = 2;
    repeated string tags = 3;
  }
  message ResolveByEdit {
    // Optional pin to the state where the resolving edit landed; empty
    // means "use the discussion's anchor state".
    bytes state_id = 1;
  }
  message ResolveDismissed {
    string reason = 1;
  }
}

message Discussion {
  string id = 1;
  PathSymbolRef anchor = 2;
  bytes opened_against_state = 3;
  google.protobuf.Timestamp opened_at = 4;
  string thread_ref = 5;
  repeated DiscussionTurn turns = 6;
  DiscussionResolution resolution = 7;
  bool body_changed_since_open = 8;
  bool orphaned = 9;
  string visibility = 10;
  string resolved_annotation_id = 11;
}

message ListDiscussionsByStateRequest {
  string repo_path = 1;
  bytes state_id = 2;
  // "open" | "resolved" | "all" | "orphaned"
  string status = 3;
}

message ListDiscussionsBySymbolRequest {
  string repo_path = 1;
  PathSymbolRef anchor = 2;
  string status = 3;
}

message ListDiscussionsResponse {
  repeated Discussion discussions = 1;
}

message GetDiscussionRequest {
  string repo_path = 1;
  string discussion_id = 2;
}

// Risk signal computation + repo-level health.
service SignalService {
  rpc ComputeStateSignals(ComputeStateSignalsRequest) returns (ComputeStateSignalsResponse);
  rpc GetRepoSignalHealth(GetRepoSignalHealthRequest) returns (RepoSignalHealthReport);
  rpc SubscribeSignalUpdates(SubscribeSignalUpdatesRequest) returns (stream SignalUpdate);
}

message ComputeStateSignalsRequest {
  string repo_path = 1;
  bytes state_id = 2;
  // Optional prior state to diff against; server defaults to the
  // state's first parent.
  bytes prior_state_id = 3;
}

message ComputeStateSignalsResponse {
  repeated RiskSignal signals = 1;
  uint32 tick_budget = 2;
}

message GetRepoSignalHealthRequest {
  string repo_path = 1;
  // Number of recent states to include in the rolling window. Server
  // applies a sane default when zero.
  uint32 window_states = 2;
}

message RepoSignalHealthReport {
  repeated SignalHealthEntry entries = 1;
  uint32 window_states = 2;
}

message SignalHealthEntry {
  string module_id = 1;
  float fire_rate = 2;
  bool warn = 3;
}

message SubscribeSignalUpdatesRequest {
  string repo_path = 1;
}

message SignalUpdate {
  bytes state_id = 1;
  repeated RiskSignal signals = 2;
}

// Structured query over the operation log + cross-cutting index.
service OperationLogQueryService {
  rpc QueryOperations(QueryOperationsRequest) returns (QueryOperationsResponse);
  rpc StreamOperations(StreamOperationsRequest) returns (stream OperationHit);
}

message QueryOperationsRequest {
  string repo_path = 1;
  string actor = 2;
  string symbol = 3;
  string signal_kind = 4;
  string thread = 5;
  repeated string verbs = 6;
  int64 since_secs = 7;
  int64 until_secs = 8;
  uint32 limit = 9;
  bool include_checkpoints = 10;
}

message QueryOperationsResponse {
  repeated OperationHit hits = 1;
}

message OperationHit {
  uint64 seq = 1;
  int64 timestamp_secs = 2;
  string verb = 3;
  string actor_email = 4;
  string operation_id = 5;
  string thread = 6;
  repeated string symbols = 7;
  repeated string signal_kinds = 8;
  bytes change_id = 9;
}

message StreamOperationsRequest {
  string repo_path = 1;
  // Same shape as QueryOperationsRequest but streamed; server may
  // chunk via a cursor for very large windows.
  QueryOperationsRequest query = 2;
}

// Transactions buffer multiple state-changing operations and apply
// them atomically on commit.
service TransactionService {
  rpc BeginTransaction(BeginTransactionRequest) returns (BeginTransactionResponse);
  rpc CommitTransaction(CommitTransactionRequest) returns (CommitTransactionResponse);
  rpc AbortTransaction(AbortTransactionRequest) returns (AbortTransactionResponse);
  rpc GetTransactionStatus(GetTransactionStatusRequest) returns (TransactionStatus);
}

message BeginTransactionRequest {
  string repo_path = 1;
  string thread = 2;
  string message = 3;
  string client_operation_id = 15;
}

message BeginTransactionResponse {
  string transaction_id = 1;
  google.protobuf.Timestamp started_at = 2;
}

message CommitTransactionRequest {
  string repo_path = 1;
  string transaction_id = 2;
  string client_operation_id = 15;
}

message CommitTransactionResponse {
  bytes state_id = 1;
  uint32 op_count = 2;
}

message AbortTransactionRequest {
  string repo_path = 1;
  string transaction_id = 2;
  string reason = 3;
  string client_operation_id = 15;
}

message AbortTransactionResponse {
  bool aborted = 1;
}

message GetTransactionStatusRequest {
  string repo_path = 1;
  string transaction_id = 2;
}

message TransactionStatus {
  string transaction_id = 1;
  // "active" | "committed" | "aborted"
  string state = 2;
  google.protobuf.Timestamp started_at = 3;
  uint32 buffered_ops = 4;
}

// Programmable hooks. The service manages hook registrations and
// brokers live event delivery: clients subscribe to the
// out-of-process event stream, the server emits events from the
// capture/merge code paths (correlated by `hook_event_id`), and hook
// implementations call back via `RespondToHook` to deliver the hook's
// JSON-encoded reply or veto.
service HookService {
  rpc RegisterHook(RegisterHookRequest) returns (Hook);
  rpc DeregisterHook(DeregisterHookRequest) returns (DeleteResponse);
  rpc ListHooks(ListHooksRequest) returns (ListHooksResponse);
  rpc GetHookEventSchema(GetHookEventSchemaRequest) returns (GetHookEventSchemaResponse);
  // Server-streamed live event delivery. Each event carries an
  // opaque `hook_event_id` correlator that the subscriber echoes
  // back to `RespondToHook`. Read-shaped — no idempotency.
  rpc SubscribeHookEvents(SubscribeHookEventsRequest) returns (stream HookEvent);
  // Deliver the hook's reply (or veto via `abort`) for a previously
  // received event. State-changing — caller supplies
  // `client_operation_id` for retry safety, though the broker will
  // also dedup on `hook_event_id` since each id is single-shot.
  rpc RespondToHook(RespondToHookRequest) returns (RespondToHookResponse);
}

message RegisterHookRequest {
  string repo_path = 1;
  string name = 2;
  string command = 3;
  // Subscribed event names. See `GetHookEventSchema` for the catalog.
  repeated string events = 4;
  uint32 timeout_ms = 5;
  string client_operation_id = 15;
}

message Hook {
  string name = 1;
  string command = 2;
  repeated string events = 3;
  uint32 timeout_ms = 4;
}

message DeregisterHookRequest {
  string repo_path = 1;
  string name = 2;
  string client_operation_id = 15;
}

message ListHooksRequest {
  string repo_path = 1;
}

message ListHooksResponse {
  repeated Hook hooks = 1;
}

message GetHookEventSchemaRequest {
  // Empty returns every registered event's schema.
  string event_name = 1;
}

message GetHookEventSchemaResponse {
  repeated HookEventSchema schemas = 1;
}

message HookEventSchema {
  string event_name = 1;
  uint32 schema_version = 2;
  // JSON-encoded JSON Schema describing the payload.
  string payload_schema_json = 3;
  // JSON-encoded JSON Schema describing the response.
  string response_schema_json = 4;
}

// Live hook event delivery messages. The subscribe RPC
// streams these to every interested hook process; each carries an
// opaque `hook_event_id` correlator so `RespondToHook` can route a
// reply back to the in-flight emitter. Payloads are JSON-encoded
// against the schemas catalogued by `GetHookEventSchema`.

message SubscribeHookEventsRequest {
  string repo_path = 1;
  // Optional event-name filter. Empty = subscribe to every event in
  // the catalog. Same wire shape as `RegisterHookRequest.events`.
  repeated string events = 2;
}

// One emitted hook event:
//   - `hook_event_id` correlates the emit-side waiter with the
//     `RespondToHook` reply.
//   - `event_name` is one of the catalog entries (`pre_capture` etc.).
//   - `payload_json` is the JSON-encoded event payload, validated by
//     the catalog's `payload_schema_json`.
message HookEvent {
  string hook_event_id = 1;
  string event_name = 2;
  string payload_json = 3;
  // Wall-clock emit time. Useful for debugging slow hooks; the
  // emit-side timeout is enforced server-side.
  google.protobuf.Timestamp emitted_at = 4;
}

message RespondToHookRequest {
  string repo_path = 1;
  // Echo from the matching `HookEvent`. Single-shot: re-using an id
  // returns `failed_precondition`.
  string hook_event_id = 2;
  // When non-empty, the operation is aborted with this string as the
  // reason. Universal veto channel — see `HookResponse.abort`.
  string abort = 3;
  // Free-form JSON-encoded extension payload. Decoded into
  // `HookResponse.extra` on the emit-side.
  string extra_signals_json = 4;
  string client_operation_id = 15;
}

message RespondToHookResponse {
  // True when the broker accepted the reply and dispatched it to the
  // waiting emitter. False when the emitter has already timed out or
  // an unrelated reply was already recorded for this id.
  bool accepted = 1;
}

// ── Support access ────────────────────────────────────────────────────────────

message GrantSupportAccessRequest {
  // Heddle staff email — must already exist as a registered user with
  // the `staff` scope marker. The grant is attached to that subject.
  string operator_email = 1;
  // Resource the grant covers. The oneof enforces "exactly one of
  // namespace / repo" at the wire level.
  GrantTargetRef target = 2;
  // TTL in seconds. The audit row's `expires_at` is computed from this
  // at grant time. Hard-capped server-side at 7 days.
  google.protobuf.Duration ttl_seconds = 3;
  // Free-form reason (incident id, ticket link, "fix p0 in deploy
  // pipeline"). Required, non-empty. Surfaced in the audit listing.
  string reason = 4;
  string client_operation_id = 15;
}

message SupportAccessGrant {
  string id = 1;
  string operator_email = 2;
  // Resource the grant covers.
  GrantTargetRef target = 3;
  // The role materialised on the underlying `hosted_grants` row.
  // Always `admin` for support access today; carried as a string so
  // future tiers (read-only support, redacted-data support) can ride
  // the same RPC.
  HostedRole role = 4;
  string granted_by = 5;
  google.protobuf.Timestamp granted_at = 6;
  google.protobuf.Timestamp expires_at = 7;
  // 0 when the grant is still active.
  google.protobuf.Timestamp revoked_at = 8;
  string revoked_by = 9;
  string reason = 10;
}

message ListSupportAccessGrantsRequest {
  // Resource to list grants on. Caller must hold Admin on the named
  // namespace, or on the repo's parent namespace.
  GrantTargetRef target = 1;
  // When true, includes grants whose audit row has been revoked or
  // whose `expires_at` is in the past. Default is active-only.
  bool include_inactive = 2;
}

message ListSupportAccessGrantsResponse {
  repeated SupportAccessGrant grants = 1;
}

message RevokeSupportAccessRequest {
  string id = 1;
  string client_operation_id = 15;
}
