// loomcycle's gRPC service surface (v0.5.5).
//
// Mirrors the existing HTTP+SSE wire shape exactly — every RPC has a
// 1:1 mapping to an HTTP route under /v1/. Both wire surfaces coexist
// on the same loomcycle process; operators pick one (or both) via
// LOOMCYCLE_GRPC_ADDR / LOOMCYCLE_LISTEN_ADDR.
//
// Compatibility:
//   - The HTTP+SSE surface is the canonical contract for now.
//     Anything that ships there must be expressible here without
//     loss; proto changes follow HTTP+SSE, not the other way.
//   - Field numbers are stable across v0.5.x. Adding a new field is
//     non-breaking; removing or renumbering one is. v0 caveats apply
//     until v1.0 is tagged.
//   - The `status` strings use the same enum the HTTP API uses
//     (running / completed / failed / cancelled) — represented as
//     proto strings, not a typed enum, so adding a new state on the
//     HTTP side doesn't require regenerating gRPC stubs everywhere.
syntax = "proto3";

package loomcycle.v1;

option go_package = "github.com/denn-gubsky/loomcycle/internal/api/grpc/loomcyclepb;loomcyclepb";

import "google/protobuf/timestamp.proto";

// ========================
// Service
// ========================

service Loomcycle {
  // Run starts a fresh agent run and server-streams its events. One
  // request → many Event responses. The stream terminates on EOF when
  // the loop reaches its terminal state (done, failed, or cancelled).
  //
  // Mirrors POST /v1/runs.
  rpc Run(RunRequest) returns (stream Event);

  // Continue extends an existing session with a new run. The session's
  // prior transcript is replayed into the loop's context; the response
  // stream covers only the NEW events from this continuation.
  //
  // Mirrors POST /v1/sessions/{id}/messages.
  rpc Continue(ContinueRequest) returns (stream Event);

  // SpawnRunBatch spawns N fresh runs concurrently in one call (RFC Y
  // external fan-out, mode "join"), bounded by the per-user admission
  // gate, returning a combined index-aligned envelope once all settle. A
  // per-child failure is captured in that child's result and never fails
  // the batch. mode "detach" (async run handles) is reserved for a future
  // release and rejected today.
  //
  // Mirrors POST /v1/runs:batch + the spawn_runs MCP tool.
  rpc SpawnRunBatch(BatchSpawnRequest) returns (BatchSpawnResult);

  // CompactRun summarizes a run's conversation to free context and
  // continue from the summary. A live run must be PARKED (awaiting input);
  // a mid-turn run returns FailedPrecondition. Keyed by run_id.
  //
  // Mirrors POST /v1/runs/{run_id}/compact + the compact_run MCP tool.
  rpc CompactRun(CompactRunRequest) returns (CompactRunResult);

  // ErasureReport enumerates what this deployment holds about one subject, in
  // three tiers separated by what they guarantee. Read-only.
  //
  // The tenant is taken from the caller's credentials and is NOT a field: a
  // subject id is only unique within one tenant, so a wire tenant would let a
  // caller enumerate another tenant's subject.
  // DirectoryUsers lists the subjects with activity in the caller's tenant, and
  // DirectoryInspect aggregates one subject's activity, chats, memory, documents,
  // budget and usage in a single call. READ-ONLY and derived — a "user" is a
  // GROUP BY over runs.user_id, not a stored row, so there is no create or update
  // here; removing a subject's footprint is ErasureExecute.
  //
  // No tenant field on either, deliberately: a subject id is only unique within
  // one tenant, so accepting one would let a caller inspect somebody else's.
  rpc DirectoryUsers(DirectoryUsersRequest) returns (DirectoryUsersResponse);
  rpc DirectoryInspect(DirectoryInspectRequest) returns (DirectoryInspectResponse);

  // DirectoryTenants enumerates tenants with derived counts. ADMIN ONLY: the
  // counts are unremarkable, but the LIST is cross-tenant information a confined
  // principal must not learn — so a non-admin is REFUSED rather than given a
  // filtered list, which would still confirm its own tenant's existence.
  rpc DirectoryTenants(DirectoryTenantsRequest) returns (DirectoryTenantsResponse);

  rpc ErasureReport(ErasureReportRequest) returns (ErasureReportResponse);

  // ErasureExecute removes tiers 1 and 2 and reports what it could not reach.
  // DEFAULTS TO A DRY RUN: dry_run is `optional` so an unset field means true,
  // matching HTTP — a client that forgets it deletes nothing.
  rpc ErasureExecute(ErasureExecuteRequest) returns (ErasureExecuteResponse);
  // ReplaySession copies a source session's transcript into a new session bound
  // to a (possibly different) target agent (RFC BJ Phase 4). Mirrors
  // POST /v1/sessions/{id}/replay.
  rpc ReplaySession(ReplaySessionRequest) returns (ReplaySessionResult);

  // RunInput pushes an operator steering message into a LIVE interactive
  // run (one parked at end_turn awaiting input, or mid-turn — the message
  // is drained at the next iteration boundary). delivered=false +
  // NotFound when no in-flight run holds run_id; ResourceExhausted when
  // the run's steer queue is full. The injected `source` is server-stamped
  // (never wire-trusted). Cross-replica routing is inherited from the
  // steer registry. Mirrors POST /v1/runs/{run_id}/input. (RFC AI)
  rpc RunInput(RunInputRequest) returns (RunInputResponse);

  // CancelTurn stops the CURRENT turn of a LIVE interactive run (its in-flight
  // model generation + the tool calls it started) and parks it at
  // awaiting_input — session + transcript intact. This is NOT whole-run cancel
  // (CancelAgent), which terminates the run. FailedPrecondition when the run
  // isn't mid-turn (not_mid_turn) or isn't interactive (not_interactive);
  // NotFound (opaque) for an unknown / cross-tenant / not-reachable run.
  // Owner-routed cross-replica by runs.replica_id.
  //
  // Mirrors POST /v1/runs/{run_id}/cancel. (RFC BH)
  rpc CancelTurn(CancelTurnRequest) returns (CancelTurnResponse);

  // ResolveInterrupt resolves a pending interruption — either an answer
  // (disposition "" / "answer", validated against the declared options) or a
  // decline (disposition "declined": no answer, skips option validation) so the
  // waiting Question tool proceeds. InvalidArgument on a bad kind / disposition
  // / answer; NotFound (opaque) for an unknown / cross-tenant interrupt;
  // FailedPrecondition when already terminal or expired.
  //
  // Mirrors POST /v1/runs/{run_id}/interrupts/{interrupt_id}/resolve. (RFC BH)
  rpc ResolveInterrupt(ResolveInterruptRequest) returns (ResolveInterruptResponse);

  // StreamRun re-attaches to a run's event stream by run_id, replaying
  // from from_seq and then live-tailing — the gRPC twin of
  // GET /v1/runs/{run_id}/stream. For a PARKED interactive run (non-
  // terminal) the stream stays open until the run ends or ctx fires; a
  // disconnect does NOT stop the run. The first frame is a synthetic
  // type="agent" Event carrying run metadata. (RFC AI)
  rpc StreamRun(StreamRunRequest) returns (stream Event);

  // GetTranscript returns the full event history for a session,
  // ordered by seq. Used by adapters that re-attach to a previously
  // started session (e.g. cross-tab sync).
  //
  // Mirrors GET /v1/sessions/{id}/transcript.
  rpc GetTranscript(GetTranscriptRequest) returns (Transcript);

  // GetAgent returns one agent's current status + usage stats. Used
  // by adapters polling for completion when the SSE/gRPC stream was
  // interrupted (browser tab closed, network blip).
  //
  // Mirrors GET /v1/agents/{agent_id}.
  rpc GetAgent(GetAgentRequest) returns (Agent);

  // CancelAgent sets the agent's terminal status to "cancelled" and
  // cascades the cancellation to every child via parent_agent_id.
  //
  // Mirrors POST /v1/agents/{agent_id}/cancel.
  rpc CancelAgent(CancelAgentRequest) returns (CancelAgentResponse);

  // ListUserAgents enumerates a user's recent agent runs, optionally
  // filtered by status.
  //
  // Mirrors GET /v1/users/{user_id}/agents.
  rpc ListUserAgents(ListUserAgentsRequest) returns (ListUserAgentsResponse);

  // UsageReport aggregates the token-usage + cost ledger (RFC AV): summed
  // tokens + cost grouped by the requested dimensions over an optional tenant +
  // time window. Tenant-scoped like the HTTP twin (a substrate:tenant caller is
  // confined to its own tenant; admin sees all + an optional tenant focus).
  //
  // Mirrors GET /v1/_usage.
  rpc UsageReport(UsageReportRequest) returns (UsageReportResponse);

  // TokenLimit manages per-scope token budgets (RFC AW): one op-based RPC
  // (op ∈ list|set|delete) mirroring GET/PUT/DELETE /v1/_limits. Tenant-scoped
  // like the HTTP twin — a substrate:tenant caller may only see/set/delete its
  // own tenant's budgets; the operator-global cap + cross-tenant rows are
  // admin-only.
  //
  // Mirrors GET/PUT/DELETE /v1/_limits.
  rpc TokenLimit(TokenLimitRequest) returns (TokenLimitResponse);

  // Health is the liveness probe. Returns build identifier + uptime.
  //
  // Mirrors GET /healthz (which is unauthenticated on the HTTP side;
  // the gRPC equivalent stays unauthenticated for parity).
  rpc Health(HealthRequest) returns (HealthResponse);

  // Config returns the instance-configuration report: build identity, the
  // feature matrix, and the live provider/model/search cascade with coarse
  // active/inactive status.
  //
  // Mirrors GET /v1/config at the CALLER's disclosure level. The HTTP side has a
  // third, narrower `public` level reachable with no bearer under
  // LOOMCYCLE_PUBLIC_CONFIG; that level does NOT exist here, because gRPC
  // authenticates before dispatch — so a caller gets `authenticated` or `admin`
  // from its own scopes. `view` in the payload names which.
  rpc Config(ConfigRequest) returns (ConfigResponse);

  // RegisterHook registers a pre- or post-tool webhook. The
  // callback_url must be an http:// or https:// endpoint the consumer
  // runs. Returns the loomcycle-assigned id. Re-registering the same
  // (owner, name) replaces the prior entry in-place (idempotent on
  // app restart).
  //
  // Mirrors POST /v1/hooks.
  rpc RegisterHook(RegisterHookRequest) returns (RegisterHookResponse);

  // ListHooks returns every currently-registered hook. Useful for
  // debug; no pagination — the registry is intentionally small
  // (operator-curated set).
  //
  // Mirrors GET /v1/hooks.
  rpc ListHooks(ListHooksRequest) returns (ListHooksResponse);

  // DeleteHook removes a registered hook by id. Returns
  // codes.NotFound when no hook has that id.
  //
  // Mirrors DELETE /v1/hooks/{id}.
  rpc DeleteHook(DeleteHookRequest) returns (DeleteHookResponse);

  // ----- v0.8.17 Pause / Resume / Snapshot (real impls in v0.8.18) -----
  //
  // Runtime-wide quiesce + cross-version-portable JSON snapshot. Wire
  // shapes mirror the connector.Connector method signatures. Typed
  // errors from internal/connector/errors.go map to gRPC codes:
  //   ErrPauseNotConfigured     → Unavailable (503-equivalent)
  //   ErrAlreadyPausing         → FailedPrecondition (409-equivalent)
  //   ErrNotPaused              → FailedPrecondition (409-equivalent)
  //   ErrSnapshotNotFound       → NotFound (404-equivalent)
  //   ErrSnapshotTooLarge       → ResourceExhausted (413-equivalent)
  //   ErrSnapshotVersionTooNew  → FailedPrecondition (422-equivalent)
  //   ErrSnapshotVersionUnknown → FailedPrecondition (422-equivalent)

  // PauseRuntime quiesces the runtime. Idempotent tools cancel
  // immediately; non-idempotent + external tools get a grace window
  // (default 30 s; max 5 min) then force-cancel. Returns 409-equivalent
  // when the runtime is already pausing or paused.
  //
  // Mirrors POST /v1/_pause.
  rpc PauseRuntime(PauseRuntimeRequest) returns (PauseRuntimeResponse);

  // ResumeRuntime releases the quiesce. Paused runs re-enter their
  // loops. Returns 409-equivalent when the runtime is not paused.
  //
  // Mirrors POST /v1/_resume.
  rpc ResumeRuntime(ResumeRuntimeRequest) returns (ResumeRuntimeResponse);

  // GetRuntimeState returns the current quiesce state for dashboards.
  //
  // Mirrors GET /v1/_state.
  rpc GetRuntimeState(GetRuntimeStateRequest) returns (RuntimeStateResponse);

  // ResolveProbe triggers an immediate, synchronous re-probe of every
  // configured provider and returns the refreshed availability matrix
  // — the operator escape hatch when a transient outage stalls every
  // provider. Unavailable when no resolver / no probe loop is wired.
  //
  // Mirrors POST /v1/_resolve/probe.
  rpc ResolveProbe(ResolveProbeRequest) returns (ResolverMatrixResponse);

  // CreateSnapshot captures running-state into a per-section-semver
  // JSON envelope (agent_defs, agent_def_active, memory, channels,
  // evaluations, paused_runs, optional interaction_history). Returns
  // 413-equivalent when the serialised envelope exceeds the configured
  // cap (LOOMCYCLE_SNAPSHOT_MAX_BYTES; default 512 MiB).
  //
  // Mirrors POST /v1/_snapshots.
  rpc CreateSnapshot(CreateSnapshotRequest) returns (SnapshotDescriptor);

  // ListSnapshots returns metadata for the most-recent snapshots
  // (capped at 200). The JSON envelope itself is not included; use
  // GetSnapshot / ExportSnapshot to fetch.
  //
  // Mirrors GET /v1/_snapshots.
  rpc ListSnapshots(ListSnapshotsRequest) returns (ListSnapshotsResponse);

  // GetSnapshot returns the full envelope including JSON content.
  // Distinct from ExportSnapshot, which is operator-facing "where did
  // this land on the host" semantics.
  //
  // Mirrors GET /v1/_snapshots/{id}.
  rpc GetSnapshot(GetSnapshotRequest) returns (SnapshotEnvelope);

  // ExportSnapshot returns canonical envelope bytes (raw_json) for a
  // snapshot id. Transports that stream large exports use raw_json
  // directly; file_path / checksum stay empty unless materialised.
  //
  // Mirrors GET /v1/_snapshots/{id}/export.
  rpc ExportSnapshot(ExportSnapshotRequest) returns (ExportSnapshotResponse);

  // RestoreSnapshot restores from a same-instance snapshot_id OR
  // cross-instance raw_json. Idempotent: ON CONFLICT DO NOTHING per
  // row. Counters in the response reflect rows actually written.
  // Returns 422-equivalent on snapshot version newer than reader
  // supports.
  //
  // Mirrors POST /v1/_snapshots/{id}/restore.
  rpc RestoreSnapshot(RestoreSnapshotRequest) returns (RestoreSnapshotResponse);

  // DeleteSnapshot removes a snapshot. Idempotent — succeeds whether
  // or not the row existed.
  //
  // Mirrors DELETE /v1/_snapshots/{id}.
  rpc DeleteSnapshot(DeleteSnapshotRequest) returns (DeleteSnapshotResponse);

  // ----- v0.8.22 substrate admin RPCs -----
  //
  // Two op-discriminated RPCs expose the AgentDef + SkillDef
  // substrate tools to gRPC callers (and through the gRPC adapter
  // path to Python clients). Same connector dispatch as the MCP
  // meta-tools and the HTTP /v1/_agentdef + /v1/_skilldef
  // endpoints — different transport, identical semantics.
  //
  // Request body is the substrate tool's input JSON (op + name +
  // def_id + overlay + ...); response carries the tool's output
  // JSON + an is_error flag. Errors that aren't tool refusals
  // (transport, auth, malformed body) come back as gRPC status
  // codes per the existing convention.

  // AgentDef dispatches to the in-process AgentDef tool. Mirrors
  // POST /v1/_agentdef.
  rpc AgentDef(SubstrateRequest) returns (SubstrateResponse);

  // SkillDef dispatches to the in-process SkillDef tool. Mirrors
  // POST /v1/_skilldef.
  rpc SkillDef(SubstrateRequest) returns (SubstrateResponse);

  // MCPServerDef dispatches to the v0.9.x dynamic MCP server
  // registration substrate. Mirrors POST /v1/_mcpserverdef.
  // Operator-admin-only — same SubstrateRequest body shape as
  // AgentDef + SkillDef (op-discriminated input_json + is_error
  // tool refusals in the response).
  rpc MCPServerDef(SubstrateRequest) returns (SubstrateResponse);

  // ScheduleDef dispatches to the v1.x RFC E scheduled-runs
  // substrate. Mirrors POST /v1/_scheduledef. Operator-admin-only.
  // Same SubstrateRequest body shape — op-discriminated input_json
  // (create / fork / get / list / retire) + is_error tool refusals
  // in the response.
  rpc ScheduleDef(SubstrateRequest) returns (SubstrateResponse);

  // A2AServerCardDef dispatches to the v1.x RFC G A2A-server-card
  // substrate. Mirrors POST /v1/_a2aservercarddef. Operator-admin-only.
  // Same SubstrateRequest body shape — op-discriminated input_json
  // (create / fork / get / list / retire) + is_error tool refusals
  // in the response.
  rpc A2AServerCardDef(SubstrateRequest) returns (SubstrateResponse);

  // A2AAgentDef dispatches to the v1.x RFC G A2A-agent substrate.
  // Mirrors POST /v1/_a2aagentdef. Operator-admin-only. Same
  // SubstrateRequest body shape — op-discriminated input_json
  // (create / fork / get / list / retire) + is_error tool refusals
  // in the response.
  rpc A2AAgentDef(SubstrateRequest) returns (SubstrateResponse);

  // WebhookDef dispatches to the v1.x RFC H inbound-webhook substrate.
  // Mirrors POST /v1/_webhookdef. Operator-admin-only. Same
  // SubstrateRequest body shape — op-discriminated input_json
  // (create / fork / get / list / retire) + is_error tool refusals
  // in the response. (RFC H WH-3 / mirrors A2AAgentDef.)
  rpc WebhookDef(SubstrateRequest) returns (SubstrateResponse);

  // MemoryBackendDef dispatches to the RFC I MR-3a memory-backend
  // substrate. Mirrors POST /v1/_memorybackenddef. Operator-admin-only.
  // Same SubstrateRequest body shape — op-discriminated input_json
  // (create / fork / get / list / retire) + is_error tool refusals
  // in the response. (RFC I MR-3a / mirrors WebhookDef.)
  rpc MemoryBackendDef(SubstrateRequest) returns (SubstrateResponse);

  // OperatorTokenDef dispatches to the RFC L OSS multi-tenant
  // authorization substrate (auth-token minting/rotation/retirement).
  // Mirrors POST /v1/_operatortokendef. Operator-admin-only. Same
  // SubstrateRequest body shape — op-discriminated input_json
  // (create / rotate / retire / get / list).
  rpc OperatorTokenDef(SubstrateRequest) returns (SubstrateResponse);

  // VolumeDef dispatches to the RFC AH dynamic filesystem-volume
  // substrate. Mirrors POST /v1/_volumedef. TENANT-CONFINED (ScopeTenant,
  // like AgentDef/SkillDef — NOT operator-admin-only): the tool stamps the
  // caller's authoritative tenant + opaque-404s cross-tenant reads. A
  // VolumeDef is flat (a pointer to mutable on-disk state, not a versioned
  // definition), so the op set is create / get / list / delete / purge —
  // NOT the content-addressed retire/promote/fork of the families above.
  // Same SubstrateRequest body shape (op-discriminated input_json +
  // is_error tool refusals in the response).
  rpc VolumeDef(SubstrateRequest) returns (SubstrateResponse);

  // TeamDef dispatches to the RFC AP team-workflow substrate. Mirrors
  // POST /v1/_teamdef. TENANT-CONFINED (ScopeTenant, like AgentDef/SkillDef):
  // the tool stamps the caller's authoritative tenant + opaque-404s
  // cross-tenant reads. Op-discriminated input_json (create / fork / get /
  // list / retire / delete / promote / verify / render_diagram / run — a
  // versioned def family; `run` walks the team's state graph, spawning each
  // state's agent under the same admission a run would get). Same
  // SubstrateRequest/Response body shape (is_error carries tool refusals).
  rpc TeamDef(SubstrateRequest) returns (SubstrateResponse);

  // Path dispatches to the RFC AL Unix-like VFS tool. Mirrors POST /v1/_path.
  // TENANT-CONFINED (ScopeTenant): scope (agent/user/tenant) is resolved from
  // the operator-trust ctx + the caller's authoritative tenant, never the
  // wire. Op-discriminated input_json (resolve / ls / stat / mkdir / mv / rm);
  // same SubstrateRequest/Response body shape (is_error carries tool refusals).
  rpc Path(SubstrateRequest) returns (SubstrateResponse);

  // Document dispatches to the RFC AK chunked-graph Document tool. Mirrors
  // POST /v1/_document. TENANT-CONFINED (ScopeTenant); requires SQL Memory.
  // Op-discriminated input_json (13 ops: document/chunk lifecycle, edges,
  // query_chunks, type defs); same SubstrateRequest/Response body shape.
  rpc Document(SubstrateRequest) returns (SubstrateResponse);

  // History dispatches to the RFC BE History tool (browse/search/annotate
  // past chats; a chat = a session). Mirrors POST /v1/_history. TENANT-CONFINED
  // (ScopeTenant): the owner is resolved from the operator-trust ctx +
  // caller's authoritative tenant, NEVER the wire — a scope:"user" op keys on
  // the principal's own subject, scope:"tenant" on its tenant; the cross-tenant
  // scope:"global" is admin-only. Op-discriminated input_json (list / get /
  // search / rename / annotate / pin / archive / recap / resume); same
  // SubstrateRequest/Response body shape (is_error carries tool refusals).
  rpc History(SubstrateRequest) returns (SubstrateResponse);

  // ----- v0.9.x n8n RFC Phase 0 -----
  //
  // ListChannels mirrors GET /v1/_channels — operator-declared
  // channels joined with runtime stats.
  rpc ListChannels(ListChannelsRequest) returns (ListChannelsResponse);

  // StreamUserRunStates mirrors GET /v1/users/{user_id}/agents/stream.
  // Server-streams one RunStateEvent per matching state transition
  // until ctx cancels or the client closes the stream.
  rpc StreamUserRunStates(StreamUserRunStatesRequest) returns (stream RunStateEvent);

  // ----- v0.9.x Channel CRUD (admin + per-user) -----
  //
  // Mirrors the four HTTP routes:
  //   PublishChannel   → POST /v1/_channels/{name}/publish (and the
  //                       /v1/users/{user_id}/channels/... variant)
  //   SubscribeChannel → POST /v1/_channels/{name}/subscribe (single-
  //                       round-trip long-poll; not streaming)
  //   PeekChannel      → GET  /v1/_channels/{name}/peek (non-destructive)
  //   AckChannel       → POST /v1/_channels/{name}/ack
  //
  // Scope + scope_id select the cursor namespace: scope="global" with
  // empty scope_id addresses the admin surface; scope="user" with a
  // non-empty scope_id addresses a per-end-user channel.
  rpc PublishChannel(PublishChannelRequest) returns (PublishChannelResponse);
  rpc SubscribeChannel(SubscribeChannelRequest) returns (SubscribeChannelResponse);
  rpc PeekChannel(PeekChannelRequest) returns (PeekChannelResponse);
  rpc AckChannel(AckChannelRequest) returns (AckChannelResponse);

  // RFC S client twins (mirror POST /v1/_channels/_await and
  // /v1/_channels/_broadcast). AwaitChannels fans IN across a set of
  // channels (any/all/at_least N, or a timeout — a timeout is
  // satisfied=false / timed_out=true, NOT an error); BroadcastChannels
  // fans OUT one payload to a set of channels (atomic at the declare
  // pre-flight). Scope + scope_id apply to every channel in the set.
  rpc AwaitChannels(AwaitChannelsRequest) returns (AwaitChannelsResponse);
  rpc BroadcastChannels(BroadcastChannelsRequest) returns (BroadcastChannelsResponse);
}

// ========================
// Run / Continue
// ========================

message RunRequest {
  // Agent name from the operator yaml's agents block.
  string agent = 1;

  // Session ID. Empty → start a fresh session; non-empty → continue
  // the named session (transcript replayed before the new turn).
  // The HTTP equivalent is "set the session_id field on POST /v1/runs".
  string session_id = 2;

  // Prompt content blocks (system + user mix). Empty → use the
  // operator-configured agent system_prompt only.
  repeated PromptSegment segments = 3;

  // Per-call tool allowlist, intersected with the agent's tools.
  // Empty → no narrowing (agent's full tool surface).
  repeated string tools = 4;

  // Per-call host allowlist for HTTP / WebFetch / WebSearch.
  // Three states (proto3 doesn't support nullable repeated, so we use
  // a wrapper):
  //   - allowed_hosts unset (oneof not selected) — no narrowing
  //   - allowed_hosts.list = [] — deny all (no host reachable)
  //   - allowed_hosts.list = [...] — intersection with operator's list
  HostAllowlist allowed_hosts = 5;

  // WebSearch result-narrowing mode. Ignored when allowed_hosts is
  // unset. "drop" (default) filters out non-allowed results;
  // "keep" returns the full set.
  string web_search_filter = 6;

  // v0.4+ tracking fields. agent_id is the caller-supplied tracking
  // handle (for cross-process status polls). user_id binds the run
  // to a user for ListUserAgents + cancel-cascade.
  string agent_id = 7;
  string user_id = 8;

  // v0.8.x per-run policy fields. Mirror the HTTP wire on
  // POST /v1/runs (handleRuns runRequest in internal/api/http/server.go).
  //
  // tenant_id is recorded on a fresh session; ignored for continuations
  // (server inherits the seed run's tenant). Empty = no tenant tag.
  string tenant_id = 9;
  // user_tier (v0.8.2+) is the user-facing tier policy name; maps to
  // cfg.UserTiers[<name>] for provider_priority + per-agent overlay.
  // Server returns InvalidArgument on unknown tier. Empty falls
  // through to cfg.UserTiers["default"] when configured.
  string user_tier = 10;
  // user_bearer (v0.8.x+) is the per-run MCP bearer token. Substituted
  // into outbound MCP HTTP header values containing `${run.user_bearer}`
  // at request-build time. Charset [A-Za-z0-9._\-+/=]{16,512}. Never
  // persisted, never logged in full. Sub-agents inherit identically.
  string user_bearer = 11;

  // user_credentials (v1.x RFC F) is the named-credentials map —
  // per-tool/per-MCP-server bearers keyed by operator-chosen name
  // (convention: the `mcp_servers.<name>` yaml key). Substituted into
  // outbound MCP HTTP header values containing
  // `${run.credentials.<name>}` at request-build time. Sub-agents
  // inherit the whole map. Keys match [a-zA-Z0-9_-]{1,64}; values
  // arbitrary strings; empty map valid. Never persisted, never logged.
  // See `docs/PLAN.md` → RFC F and `Context.help per-run-credentials`.
  map<string, string> user_credentials = 12;

  // sampling (v0.28.0) is the per-run LLM sampling override, merged
  // per-field over the agent's own sampling (unset fields inherit).
  // Absent = inherit entirely. Mirrors the HTTP /v1/runs `sampling` field.
  Sampling sampling = 13;

  // compaction (v0.32.0) is the per-run context-compaction override,
  // merged per-field over the agent's own block (unset fields inherit).
  // Absent = inherit entirely. Mirrors the HTTP /v1/runs `compaction` field.
  Compaction compaction = 14;

  // interactive (RFC AI) starts a PERSISTENT run that parks at end_turn
  // awaiting operator steering instead of terminating. Drive it with the
  // RunInput RPC; re-attach with StreamRun; cancel to end it. Mirrors
  // POST /v1/runs `interactive`.
  bool interactive = 15;
}

message ContinueRequest {
  string session_id = 1;
  repeated PromptSegment segments = 2;
  repeated string tools = 3;
  HostAllowlist allowed_hosts = 4;
  string web_search_filter = 5;
  string agent_id = 6;  // fresh tracking handle for this continuation

  // v0.8.x per-call policy fields. Continuations re-supply these each
  // turn (rather than inheriting from the seed run) so a user upgrading
  // mid-session sees the new tier on the next turn. tenant_id is NOT
  // accepted on Continue — the server inherits it from the session.
  string user_tier = 7;
  string user_bearer = 8;

  // v1.x RFC F per-tool credentials map. Same shape + semantics as
  // RunRequest.user_credentials; supplied per-continuation.
  map<string, string> user_credentials = 9;

  // v0.28.0 / v0.32.0 per-continuation overrides (mirror RunRequest).
  Sampling sampling = 10;
  Compaction compaction = 11;

  // interactive (RFC AI) — a continuation can also park at end_turn for
  // operator steering. Mirrors the HTTP continuation `interactive` field.
  bool interactive = 12;
}

// Sampling mirrors config.Sampling — the per-run LLM sampling override.
// Every field is proto3 `optional` so "unset" (inherit the agent's value)
// is distinguishable from an explicit zero (temperature 0.0 is
// deterministic, NOT "unspecified").
message Sampling {
  optional double temperature = 1;
  optional double top_p = 2;
  optional int32 top_k = 3;
  optional double frequency_penalty = 4;
  optional double presence_penalty = 5;
  optional int32 seed = 6;
  repeated string stop = 7;
}

// Compaction mirrors config.Compaction — the per-run context-compaction
// override. `optional` preserves unset-vs-explicit (enabled=false is "off",
// absent is "inherit").
message Compaction {
  optional bool enabled = 1;
  optional int32 target_percentage = 2;
  optional int32 keep_last_n = 3;
  optional bool keep_first = 4;
  optional int32 autocompact_at_pct = 5;
  optional string model = 6;
}

// ========================
// Fan-out (RFC Y) + Compaction
// ========================

// BatchSpawnRequest is the input to SpawnRunBatch. Each spawn reuses the
// RunRequest shape (its session_id is ignored — batch children are always
// fresh runs). Capped at 32; the server rejects an over-cap batch.
message BatchSpawnRequest {
  repeated RunRequest spawns = 1;
  // mode is "join" (default): block until all children settle. "detach"
  // (async run handles) is reserved for a future release and rejected.
  string mode = 2;
  // timeout_ms optionally caps the join; a child still running when it
  // elapses is cancelled and reported with a cancelled status in-envelope.
  int64 timeout_ms = 3;
}

// SpawnResult mirrors connector.SpawnRunResult — one child's outcome. A
// per-child failure is reported here (status + error), never as an RPC error.
message SpawnResult {
  string agent_id = 1;
  string run_id = 2;
  string session_id = 3;
  string status = 4;  // completed | failed | cancelled
  string stop_reason = 5;
  string final_text = 6;
  Usage usage = 7;
  string error = 8;
}

// BatchSpawnResult is the combined "join"-mode outcome; results is
// index-aligned with the request's spawns.
message BatchSpawnResult {
  repeated SpawnResult results = 1;
  int32 spawned = 2;
}

// CompactRunRequest targets a run by run_id (the HTTP path keys on it; the
// MCP tool resolves agent_id→run_id before calling).
// --- RFC BL P5: subject erasure -------------------------------------------

// --- directory: who is here, and what is held for them ----------------------

message DirectoryUsersRequest {}

// DirectoryUser is one DERIVED user: activity only, which is all runs.user_id
// supports. There is no profile because there is no user record.
message DirectoryUser {
  string subject = 1;
  int32 running_count = 2;
  int32 total_count = 3;
  string last_started_at = 4; // RFC3339 UTC; empty when never started
}

message DirectoryUsersResponse {
  string tenant = 1;
  repeated DirectoryUser users = 2;
}

message DirectoryInspectRequest {
  string subject = 1;
}

// DirectoryBudget uses `optional` on both fields because nil and zero differ:
// unset is "no ceiling at this tier", zero would be a ceiling refusing every run.
message DirectoryBudget {
  optional int64 soft_limit = 1;
  optional int64 hard_limit = 2;
}

message DirectoryUsage {
  int64 calls = 1;
  double cost = 2;
}

message DirectoryInspectResponse {
  string tenant = 1;
  string subject = 2;
  DirectoryUser activity = 3;
  int32 chats = 4;
  // memory is per-scope rather than one number: a subject's own rows and what a
  // shared agent holds ABOUT them are different things.
  map<string, int64> memory = 5;
  // documents is `optional` so "not examined" (no SQL Memory configured) stays
  // distinguishable from "examined, found none".
  optional int32 documents = 6;
  DirectoryBudget budget = 7;
  DirectoryUsage usage = 8;
  // errors names planes that could not be READ. Non-empty means every count is a
  // lower bound.
  repeated string errors = 9;
  repeated string notes = 10;
}

message DirectoryTenantsRequest {}

message DirectoryTenant {
  string tenant = 1;
  int32 users = 2;
  int32 runs = 3;
}

message DirectoryTenantsResponse {
  repeated DirectoryTenant tenants = 1;
  repeated string notes = 2;
}

message ErasureReportRequest {
  string subject = 1;
}

// ErasureTier is one tier's per-plane counts. A key's PRESENCE means the plane
// was examined; its value is the row count. Absence means NOT examined, which
// is a different statement from zero.
message ErasureTier {
  map<string, int64> counts = 1;
  int64 total = 2;
}

// ErasureResidue is what a subject-keyed delete cannot reach. sessions_examined
// distinguishes "nothing derives from them" from "nothing to trace from": rows=0
// across sessions=0 means UNKNOWN, not NONE.
message ErasureResidue {
  int32 rows = 1;
  repeated string scopes = 2;
  int32 sessions_examined = 3;
  bool truncated = 4;
}

message ErasureReportResponse {
  string tenant = 1;
  string subject = 2;
  ErasureTier tier1_covered = 3;
  ErasureTier tier2_uncovered = 4;
  ErasureResidue tier3_residue = 5;
  repeated string notes = 6;
  // errors names planes that could not be READ. A plane that failed is not a
  // plane that is empty; when this is non-empty every count is a lower bound.
  repeated string errors = 7;
}

message ErasureExecuteRequest {
  string subject = 1;
  // optional so UNSET means dry run. A plain bool would make the zero value
  // destructive, which is the one default this operation must not have.
  optional bool dry_run = 2;
  // must equal subject when dry_run is false.
  string confirm = 3;
}

message ErasureExecuteResponse {
  string tenant = 1;
  string subject = 2;
  bool dry_run = 3;
  map<string, int32> deleted = 4;
  // retained is plane -> why it was kept, never empty: an erasure listing only
  // its successes reads as complete.
  map<string, string> retained = 5;
  ErasureResidue residue = 6;
  repeated string errors = 7;
  repeated string notes = 8;
}

message CompactRunRequest {
  string run_id = 1;
  string reason = 2;  // optional audit note
}

// CompactRunResult mirrors connector.CompactResult.
message CompactRunResult {
  string run_id = 1;
  bool compacted = 2;
  int32 before_tokens = 3;
  int32 after_tokens = 4;
  string applied = 5;  // live | marker | noop
}

message ReplaySessionRequest {
  string session_id = 1;  // SOURCE session whose transcript is carried over
  string agent = 2;       // target agent the new session binds
  bool compress = 3;      // collapse the carried history to a summary + recent tail
}
message ReplaySessionResult {
  string new_session_id = 1;
  string seed_run_id = 2;
  int32 events_copied = 3;
  bool compacted = 4;
}

// RunInputRequest is one operator steering message for a live run (RFC AI).
message RunInputRequest {
  string run_id = 1;
  string text = 2;
  // source is advisory/forward-compat — the server stamps the authoritative
  // source from the auth boundary (gRPC has no cookie → "api"), never trusts
  // this field.
  string source = 3;
}

// RunInputResponse mirrors the HTTP POST /v1/runs/{run_id}/input reply.
message RunInputResponse {
  string run_id = 1;
  bool delivered = 2;
}

// CancelTurnRequest stops the current turn of a live interactive run (RFC BH).
message CancelTurnRequest {
  string run_id = 1;
  string reason = 2;  // optional operator annotation carried on the cancel cause
}

// CancelTurnResponse mirrors the HTTP POST /v1/runs/{run_id}/cancel reply.
message CancelTurnResponse {
  string run_id = 1;
  bool stopped = 2;
  bool parked = 3;
}

// ResolveInterruptRequest resolves a pending interruption (RFC BH). The
// disposition selects the terminal path: "" / "answer" carry an answer;
// "declined" carries none and skips option validation.
message ResolveInterruptRequest {
  string run_id = 1;
  string interrupt_id = 2;
  string kind = 3;         // "question" (default when empty)
  string answer = 4;       // for the answer disposition
  string resolved_by = 5;  // audit attribution; server stamps "api" when empty
  string disposition = 6;  // "" | "answer" | "declined"
}

// ResolveInterruptResponse mirrors the HTTP resolve reply.
message ResolveInterruptResponse {
  string interrupt_id = 1;
  string status = 2;  // "resolved" | "declined"
}

// StreamRunRequest re-attaches to a run's event stream by run_id (RFC AI).
message StreamRunRequest {
  string run_id = 1;
  int64 from_seq = 2;  // replay events with seq > from_seq, then live-tail
}

// HostAllowlist wraps a repeated string so the absence-of-list state
// is distinguishable from the empty-list state on the wire.
message HostAllowlist {
  repeated string list = 1;
}

// PromptSegment mirrors loop.PromptSegment. Role is "system" | "user"
// | "assistant"; in practice, gRPC callers send only "system" and
// "user" segments and let the loop produce "assistant" turns.
message PromptSegment {
  string role = 1;
  repeated PromptContentBlock content = 2;
}

// PromptContentBlock mirrors loop.PromptContentBlock. The trust
// vocabulary stays HTTP-compatible:
//   - "trusted-text" / "user-text" / "tool-output-text" / "third-party-text"
// Cacheable=true marks this block for native provider caching
// (Anthropic cache_control). Ignored on providers that don't support it.
message PromptContentBlock {
  string type = 1;
  string text = 2;
  bool cacheable = 3;
  // Image input (type == "image", RFC AT). media_type is a whitelisted
  // image media type (image/png|jpeg|gif|webp); data is the RAW image bytes.
  // Unlike the HTTP/JSON wire (which carries base64 in a string, since JSON
  // has no byte type), gRPC carries the bytes natively here — the server
  // base64-encodes them into the loop's internal representation. There is
  // deliberately no URL form (SSRF — RFC AT §6).
  string media_type = 4;
  bytes data = 5;
}

// ========================
// Event (server-stream)
// ========================

// Event is one frame on the Run/Continue server stream. Mirrors
// providers.Event 1:1 — fields stay nullable (proto3 message-type
// fields default to nil-equivalent) so the wire shape can carry
// "this event is just text" or "this event is a tool_use" or "this
// event is a usage update" without per-type subclassing.
message Event {
  string type = 1;  // text | tool_use | tool_result | usage | retry | done | error | host_widened
  string text = 2;
  ToolUse tool_use = 3;
  Usage usage = 4;
  string error = 5;
  bool is_error = 6;        // tool_result with execution failure
  Retry retry = 7;
  string stop_reason = 8;   // end_turn | tool_use | max_tokens | stop_sequence
  // host_widening carries the structured payload on type=host_widened
  // frames (v0.8.17+). Emitted once per dispatched tool call whose
  // Pre-hook allow_hosts grant fired. Nil on every other event type.
  HostWidening host_widening = 9;

  // awaiting_input carries the payload on type=awaiting_input frames
  // (RFC AI) — a persistent interactive run parked at end_turn. Nil on
  // every other event type.
  AwaitingInput awaiting_input = 10;

  // user_input carries the payload on type=steer frames (RFC AI) — an
  // operator steering message drained into the conversation. Also emitted
  // by StreamRun re-attach when replaying persisted operator turns
  // (source="replay"). Nil on every other event type.
  UserInput user_input = 11;

  // limit carries the payload on type=limit frames (RFC AW) — a per-scope
  // token-budget crossing (soft warning at run start, or a soft crossing
  // mid-run). SERVER-generated. Nil on every other event type.
  LimitInfo limit = 12;
}

// AwaitingInput is the type=awaiting_input payload (RFC AI). Mirrors
// providers.AwaitingInputEventInfo.
message AwaitingInput {
  int32 since_turn = 1;  // the iteration the run parked after
}

// UserInput is the type=steer payload (RFC AI). Mirrors
// providers.UserInputEventInfo.
message UserInput {
  string text = 1;
  string source = 2;   // api | webui | replay
  string seen_at = 3;  // RFC3339Nano timestamp the message was drained
}

// HostWidening is the structured payload on EventHostWidened frames
// (v0.8.17+). Operators correlate (tool_call_id, url, hosts_added)
// to detect confused-deputy patterns where the hook is just echoing
// the model's requested host without independent validation.
// Mirrors providers.HostWideningEventInfo on the Go side.
message HostWidening {
  string tool_call_id = 1;  // loop-issued tool_use id; same as the surrounding EventToolCall
  string tool_name = 2;      // "HTTP" / "WebFetch" / etc.
  string url = 3;            // originating URL the model asked the tool to fetch
  string hook_owner = 4;     // (owner, name) identifies the registered hook
  string hook_name = 5;
  repeated string hosts_added = 6;
}

// ToolUse carries one tool_use block emitted by the model. The id is
// the model's correlator (Claude tu_*, OpenAI call_*); name is the
// tool's registered name; input is the raw JSON the model produced.
message ToolUse {
  string id = 1;
  string name = 2;
  bytes input = 3;
}

// Usage is the per-call token accounting. CacheCreation/Read tokens
// surface on Anthropic provider responses; zero on others.
message Usage {
  int64 input_tokens = 1;
  int64 output_tokens = 2;
  int64 cache_creation_tokens = 3;
  int64 cache_read_tokens = 4;
  string model = 5;
}

// Retry mirrors providers.RetryInfo. Fired when a provider 429 is
// being retried with backoff; gives adapters live "waiting on rate
// limit" telemetry.
message Retry {
  string provider = 1;
  int32 attempt = 2;
  int64 wait_ms = 3;
  string reason = 4;  // RetryReasonHeader | RetryReasonSchedule
}

// LimitInfo is the structured payload on type=limit frames (RFC AW — per-scope
// token budgets). Names which scope tripped, how hard, and where the scope
// stands against its ceiling. No secrets: scope/scope_id are a tenant/subject
// id (already non-secret, like user_id) and the counts are integers. Mirrors
// providers.LimitInfo on the Go side.
message LimitInfo {
  string scope = 1;      // "operator" | "tenant" | "user"
  string scope_id = 2;   // tenant id (scope=tenant), user subject (scope=user), "" (operator)
  string severity = 3;   // "soft" | "hard"
  string window = 4;     // "month" (calendar month, UTC) in Phase 1
  int64 used = 5;        // scope's month-to-date token total at the crossing
  int64 limit = 6;       // the tier that was crossed (the soft or hard ceiling)
  string message = 7;    // human-readable banner (optional)
}

// ========================
// Transcript
// ========================

message GetTranscriptRequest {
  string session_id = 1;
}

message Transcript {
  repeated TranscriptEvent events = 1;
}

// TranscriptEvent is one persisted store.Event. The payload is raw
// JSON bytes (matching the HTTP transcript surface) so adapters
// decode the same way regardless of wire choice.
message TranscriptEvent {
  int64 seq = 1;
  string session_id = 2;
  string run_id = 3;
  google.protobuf.Timestamp ts = 4;
  string type = 5;
  bytes payload = 6;
}

// ========================
// Agent (status)
// ========================

message GetAgentRequest {
  string agent_id = 1;
}

// Agent mirrors HTTP's agentResponse. Status is a string (running |
// completed | failed | cancelled) so the wire stays compatible with
// future status additions without regen.
message Agent {
  string agent_id = 1;
  string run_id = 2;
  string session_id = 3;
  string user_id = 4;
  string parent_agent_id = 5;
  string status = 6;
  google.protobuf.Timestamp started_at = 7;
  google.protobuf.Timestamp completed_at = 8;
  string stop_reason = 9;
  string error = 10;
  AgentUsage usage = 11;
  google.protobuf.Timestamp last_heartbeat_at = 12;
  // Live = true when the agent is still in the cancel registry (i.e.
  // cancellable). Adapters use this to distinguish "running and
  // cancellable" from "running per the row but the registry doesn't
  // know about it" (process restart leaves the latter).
  bool live = 13;
}

// AgentUsage mirrors agentResponseUsage.
message AgentUsage {
  int64 input_tokens = 1;
  int64 output_tokens = 2;
  int64 cache_creation_tokens = 3;
  int64 cache_read_tokens = 4;
  string model = 5;
}

message CancelAgentRequest {
  string agent_id = 1;
  string reason = 2;
}

message CancelAgentResponse {
  // cancelled_count is the number of agents (this one plus any
  // descendants reached via parent_agent_id walk) marked cancelled.
  int32 cancelled_count = 1;
}

message ListUserAgentsRequest {
  string user_id = 1;
  // Optional status filter ("running" | "completed" | "failed" |
  // "cancelled"). Empty → all statuses.
  string status = 2;
}

message ListUserAgentsResponse {
  repeated Agent agents = 1;
}

// UsageReportRequest mirrors GET /v1/_usage query params. group_by is a subset
// of {tenant,user,provider,model,source}; an unknown dimension is rejected. from
// / to are RFC3339 (empty = unbounded). tenant is an admin-only focus (ignored
// for a scoped principal, which is confined to its own tenant).
message UsageReportRequest {
  repeated string group_by = 1;
  // RFC3339 window bounds (empty = unbounded). Named *_time (not from/to) so the
  // generated Python field names don't collide with the `from` keyword.
  string from_time = 2;
  string to_time = 3;
  string tenant = 4;
}

// UsageAggregate is one grouped row; only the grouped dimensions are populated.
message UsageAggregate {
  string tenant_id = 1;
  string user_id = 2;
  string provider = 3;
  string model = 4;
  string credential_source = 5;
  int64 input_tokens = 6;
  int64 output_tokens = 7;
  int64 cache_creation_tokens = 8;
  int64 cache_read_tokens = 9;
  double cost = 10;
  string currency = 11;
  int64 call_count = 12;
  int64 unpriced_calls = 13;
}

message UsageReportResponse {
  repeated string group_by = 1;
  repeated UsageAggregate rows = 2;
}

// TokenLimitRequest is the op-based token-budget management RPC (RFC AW),
// mirroring GET/PUT/DELETE /v1/_limits. op ∈ {list,set,delete}:
//   - list:   returns every budget the caller may see (tenant-scoped). scope /
//             scope_id / soft_limit / hard_limit ignored; tenant is an
//             admin-only focus (ignored for a scoped principal).
//   - set:    upserts one budget row for (scope, scope_id). soft_limit /
//             hard_limit are optional — PRESENT sets that tier, ABSENT clears it
//             (unlimited on that axis), so a full-row upsert matches the HTTP
//             PUT semantics (soft_limit:0 is a real zero ceiling ≠ unset).
//   - delete: removes the budget for (scope, scope_id) → that scope is unlimited.
// scope is "operator" | "tenant" | "user"; scope_id is the user subject for
// scope=user, empty otherwise. tenant is the target tenant (admin only; a
// scoped caller is confined to its own tenant regardless of this field).
message TokenLimitRequest {
  string op = 1;                 // "list" | "set" | "delete"
  string tenant = 2;
  string scope = 3;
  string scope_id = 4;
  optional int64 soft_limit = 5; // present = set that tier, absent = unset (unlimited)
  optional int64 hard_limit = 6;
}

// TokenLimitEntry is one token_limits row plus its live month-to-date usage.
// soft_limit / hard_limit are optional: absent = that tier unset (no ceiling).
message TokenLimitEntry {
  string tenant_id = 1;
  string scope = 2;
  string scope_id = 3;
  optional int64 soft_limit = 4;
  optional int64 hard_limit = 5;
  int64 used = 6;          // scope's current month-to-date token total
  string updated_at = 7;   // RFC3339 (empty when unknown)
  string updated_by = 8;   // principal subject that last wrote the row
}

// TokenLimitResponse carries the caller-visible budgets. Always populated for
// op=list; for op=set it echoes the written row; empty for op=delete.
message TokenLimitResponse {
  repeated TokenLimitEntry limits = 1;
}

// ========================
// Health
// ========================

message ConfigRequest {}

// ConfigResponse carries the report as raw JSON rather than a typed message.
//
// Two reasons, both structural. The report's `features` values change SHAPE by
// disclosure level — nested objects for an authenticated caller, plain booleans
// for a public one — which no single proto message expresses honestly. And the
// matrix grows as the runtime gains subsystems, so a typed mirror would need a
// proto field per capability, in lockstep, forever; a client that wants a new
// capability's flag would need a regenerated stub to see a boolean. Passing the
// JSON through means gRPC and HTTP cannot serialize the same report differently,
// which is the same anti-drift reason the capability probe itself is shared.
//
// This follows the SubstrateResponse precedent (bytes output_json) already used
// by fourteen RPCs.
message ConfigResponse {
  bytes config_json = 1;
}

message HealthRequest {}

message HealthResponse {
  bool ok = 1;
  string commit = 2;
  string built = 3;
  // Uptime in seconds since process start.
  int64 uptime_seconds = 4;
}

// ========================
// Hook management
// ========================
//
// phase + fail_mode stay as proto strings (not enums) for the same
// reason `status` does — adding a new value on the HTTP side doesn't
// require regenerating stubs everywhere. The hooks.Phase / hooks.FailMode
// Go types are already string-typed so there's no impedance to convert.

message RegisterHookRequest {
  // App UID; (owner, name) is the identity tuple. Re-registering the
  // same pair replaces the prior registration with a fresh id.
  string owner = 1;
  string name = 2;
  // "pre" | "post". Anything else → InvalidArgument.
  string phase = 3;
  // Agent name globs (exact match or trailing-* prefix). Empty list
  // matches every agent (equivalent to ["*"]).
  repeated string agents = 4;
  // Tool name globs (same syntax as agents). Empty matches every tool.
  repeated string tools = 5;
  // http:// or https:// endpoint the consumer runs. The dispatcher
  // POSTs PreHookCall / PostHookCall payloads here.
  string callback_url = 6;
  // "open" (default) | "closed". Open = webhook errors pass through;
  // closed = errors fail the tool call with IsError=true.
  string fail_mode = 7;
  // Per-call timeout. 0 = registry default (5 s).
  int32 timeout_ms = 8;
}

message RegisterHookResponse {
  // Loomcycle-assigned id. Caller uses this on DeleteHook.
  string id = 1;
}

message ListHooksRequest {}

// Hook mirrors hooks.Hook for the wire — the full descriptor as it
// lives in the in-memory registry. The bool `Timeout` is the resolved
// duration; we don't surface it on the wire (callers care about
// timeout_ms which is the source).
message Hook {
  string id = 1;
  string owner = 2;
  string name = 3;
  string phase = 4;
  repeated string agents = 5;
  repeated string tools = 6;
  string callback_url = 7;
  string fail_mode = 8;
  int32 timeout_ms = 9;
  google.protobuf.Timestamp registered_at = 10;
}

message ListHooksResponse {
  repeated Hook hooks = 1;
}

message DeleteHookRequest {
  string id = 1;
}

message DeleteHookResponse {
  // Echoed id of the deleted hook. Empty when no row matched (the
  // RPC returns codes.NotFound in that case; this field is for the
  // success path's audit trail).
  string deleted = 1;
}

// ========================
// Pause / Resume / State (v0.8.17 primitives; gRPC surface v0.8.18)
// ========================

message PauseRuntimeRequest {
  // Wait-for-non-idempotent-tools cap, in milliseconds. 0 falls
  // through to LOOMCYCLE_PAUSE_DEFAULT_TIMEOUT_MS (default 30 s).
  // Clamped server-side at pause.MaxPauseTimeout (5 min). int64
  // for consistency with every other _ms field in this proto
  // (duration_ms in PauseRuntimeResponse, wait_ms in Retry) —
  // server clamping makes the larger range unobservable but the
  // type matches.
  int64 timeout_ms = 1;
}

message PauseRuntimeResponse {
  string status = 1;              // "paused"
  int64 duration_ms = 2;
  int32 force_cancelled_count = 3;
  int32 paused_runs_count = 4;
  repeated string warnings = 5;
}

message ResumeRuntimeRequest {}

message ResumeRuntimeResponse {
  string status = 1;              // "running"
  int32 resumed_run_count = 2;
  repeated string warnings = 3;
}

message GetRuntimeStateRequest {}

message RuntimeStateResponse {
  string status = 1;              // "running" | "pausing" | "paused"
  google.protobuf.Timestamp paused_at = 2;
  int32 paused_run_count = 3;
  int32 snapshots_count = 4;
}

message ResolveProbeRequest {}

// ResolverMatrixResponse mirrors the GET /v1/_resolver wire shape —
// the availability matrix captured right after a forced re-probe.
message ResolverMatrixResponse {
  google.protobuf.Timestamp generated_at = 1;
  map<string, ResolverProviderAvailability> providers = 2;
}

message ResolverProviderAvailability {
  bool excluded = 1;
  bool reachable = 2;
  map<string, ResolverModelStatus> models = 3;
  google.protobuf.Timestamp last_check = 4;
  string last_error = 5;
}

message ResolverModelStatus {
  bool listed = 1;
  bool stalled = 2;
}

// ========================
// Snapshot (v0.8.17 primitives; gRPC surface v0.8.18)
// ========================

message CreateSnapshotRequest {
  bool include_history = 1;
  google.protobuf.Timestamp since_ts = 2;
  string description = 3;
  // Override LOOMCYCLE_SNAPSHOT_MAX_BYTES for this call. 0 = default.
  int64 max_bytes = 4;
}

// SnapshotDescriptor is the metadata-only view used by CreateSnapshot
// + ListSnapshots. The full JSON envelope lives in GetSnapshot /
// ExportSnapshot responses.
message SnapshotDescriptor {
  string snapshot_id = 1;
  google.protobuf.Timestamp created_at = 2;
  int64 size_bytes = 3;
  bool includes_history = 4;
  google.protobuf.Timestamp since_ts = 5;
  string description = 6;
  string format_version = 7;
}

message ListSnapshotsRequest {}

message ListSnapshotsResponse {
  repeated SnapshotDescriptor snapshots = 1;
}

message GetSnapshotRequest {
  string snapshot_id = 1;
}

// SnapshotEnvelope carries the full row including JSON content.
// Distinct from ExportSnapshotResponse, which is operator-facing
// "where did this land on the host" semantics.
message SnapshotEnvelope {
  string snapshot_id = 1;
  google.protobuf.Timestamp created_at = 2;
  string description = 3;
  string format_version = 4;
  int64 size_bytes = 5;
  // Canonical envelope bytes.
  bytes json_content = 6;
}

message ExportSnapshotRequest {
  string snapshot_id = 1;
}

message ExportSnapshotResponse {
  string snapshot_id = 1;
  // file_path + checksum are populated when a transport materialises
  // the export to disk first (CLI's --out flag). Otherwise empty —
  // raw_json carries the canonical bytes directly.
  string file_path = 2;
  string checksum = 3;
  int64 size_bytes = 4;
  bytes raw_json = 5;
}

message RestoreSnapshotRequest {
  // Exactly one of snapshot_id (same-instance lookup) or raw_json
  // (cross-instance restore from inline bytes) must be non-empty.
  // file_path restore is intentionally not supported on the gRPC
  // surface — supply raw_json inline (CLI reads the file client-side).
  string snapshot_id = 1;
  bytes raw_json = 2;
  bool include_history = 3;
}

message RestoreSnapshotResponse {
  // Per-section counters. Reflect rows actually written
  // (ON CONFLICT DO NOTHING — a re-restore reports 0 even though
  // every per-row INSERT "succeeded").
  int32 agent_defs_restored = 1;
  int32 agent_def_active_restored = 2;
  int32 memory_restored = 3;
  int32 channel_messages_restored = 4;
  int32 channel_cursors_restored = 5;
  int32 evaluations_restored = 6;
  int32 paused_runs_restored = 7;
  // Sessions synthesized for cross-instance restore (paused_runs
  // reference session_ids; restore creates snap_sess_<run_id>
  // entries deterministically when needed).
  int32 synthesized_sessions = 8;
  int32 transcript_events_restored = 9;
  int32 interaction_history_restored = 10;
  repeated string warnings = 11;
  repeated string format_migrations = 12;
}

message DeleteSnapshotRequest {
  string snapshot_id = 1;
}

message DeleteSnapshotResponse {
  // Always true — DELETE is idempotent (returns ok whether or not
  // the row existed; mirrors HTTP DELETE /v1/_snapshots/{id} = 204).
  bool deleted = 1;
  string snapshot_id = 2;
}

// ========================
// Substrate admin (v0.8.22)
// ========================

// SubstrateRequest carries the substrate tool's input JSON
// verbatim. Shape (op + name + def_id + overlay + ...) is owned
// by the in-process tool; the gRPC layer is content-agnostic.
message SubstrateRequest {
  bytes input_json = 1;
}

// SubstrateResponse carries the tool's output JSON + a flag
// distinguishing successful output from a tool-level refusal.
// `is_error == true` means the call reached the tool but the
// tool refused (scope deny, empty-body, etc.); `output_json`
// then carries the human-readable error text. Transport-level
// failures (auth, malformed input) come back as gRPC status
// codes, not in this body.
message SubstrateResponse {
  bytes output_json = 1;
  bool is_error = 2;
}

// ========================
// v0.9.x n8n RFC Phase 0
// ========================

// ListChannelsRequest is empty — the channel list is global within
// the loomcycle process, no per-call selectors.
message ListChannelsRequest {}

message ListChannelsResponse {
  repeated ChannelDescriptor channels = 1;
}

message ChannelDescriptor {
  string name = 1;
  string scope = 2;
  string semantic = 3;
  string publisher = 4;
  string period = 5;
  int32 default_ttl = 6;
  int32 max_messages = 7;
  int64 message_count = 8;
  // RFC3339 strings — empty when count == 0.
  string oldest_visible_at = 9;
  string newest_visible_at = 10;
}

message StreamUserRunStatesRequest {
  string user_id = 1;
  // Optional filter — empty means all transitions match.
  repeated string statuses = 2;
  // Optional filter — empty means any agent matches.
  string agent = 3;
}

// RunStateEvent mirrors the SSE wire shape from the HTTP transport.
// TS is RFC3339 for language-portability.
message RunStateEvent {
  string run_id = 1;
  string agent_id = 2;
  string agent = 3;
  string user_id = 4;
  string parent_agent_id = 5;
  string status = 6;
  string stop_reason = 7;
  string error = 8;
  string ts = 9;
}

// ========================
// v0.9.x Channel CRUD
// ========================
//
// scope is "global" or "user"; scope_id is the user_id when scope==user,
// empty otherwise. The wire shape is symmetrical across the four ops so
// callers can build one helper with a (channel, scope, scope_id) tuple.

message PublishChannelRequest {
  string channel = 1;
  string scope = 2;
  string scope_id = 3;
  // payload is raw JSON (bytes so the proto layer doesn't have to
  // model the user's JSON schema). The Go-side connector validates
  // json.Valid + the configured size cap.
  bytes payload = 4;
  // RFC3339Nano; empty = publish immediately.
  string deliver_at = 5;
}

message PublishChannelResponse {
  string msg_id = 1;
  string channel = 2;
  string created_at = 3;  // RFC3339Nano
  string visible_at = 4;  // RFC3339Nano; empty when not deferred
}

message SubscribeChannelRequest {
  string channel = 1;
  string scope = 2;
  string scope_id = 3;
  string from_cursor = 4;  // empty = committed cursor; "cur_0" = replay from oldest
  int32 max_messages = 5;  // 0 = default 10; capped at 100
  int32 wait_ms = 6;       // 0 = poll-and-return; capped by operator's LongPollCapMS
}

message SubscribeChannelResponse {
  string channel = 1;
  repeated ChannelMessage messages = 2;
  string next_cursor = 3;
}

// ChannelMessage is one delivered message. value is the raw JSON
// payload (bytes for the same reason as PublishChannelRequest.payload).
message ChannelMessage {
  string id = 1;
  bytes value = 2;
  string published_at = 3;  // RFC3339Nano
}

message PeekChannelRequest {
  string channel = 1;
  string scope = 2;
  string scope_id = 3;
  string from_cursor = 4;
  int32 max_messages = 5;
}

message PeekChannelResponse {
  string channel = 1;
  repeated ChannelMessage messages = 2;
}

message AckChannelRequest {
  string channel = 1;
  string scope = 2;
  string scope_id = 3;
  string cursor = 4;
}

message AckChannelResponse {
  bool ok = 1;
}

// RFC S client twins. AwaitChannels fans IN across `channels`; reads are
// NON-committing. BroadcastChannels fans OUT `payload` to `channels`.
message AwaitChannelsRequest {
  repeated string channels = 1;
  string scope = 2;
  string scope_id = 3;
  string mode = 4;          // any | all | at_least; default any
  int32 n = 5;              // threshold for at_least (>0)
  string from_cursor = 6;
  int32 max_messages = 7;
  int32 wait_ms = 8;
}

// One fired channel's accumulated messages + the (non-advanced) cursor.
message AwaitChannelEntry {
  repeated ChannelMessage messages = 1;
  string next_cursor = 2;
}

message AwaitChannelsResponse {
  bool satisfied = 1;
  bool timed_out = 2;
  string mode = 3;
  repeated string fired = 4;
  int32 total_messages = 5;
  map<string, AwaitChannelEntry> results = 6;
}

message BroadcastChannelsRequest {
  repeated string channels = 1;
  string scope = 2;
  string scope_id = 3;
  // payload is raw JSON (bytes, same as PublishChannelRequest.payload).
  bytes payload = 4;
  string deliver_at = 5;  // RFC3339Nano; empty = publish immediately
}

// One channel's publish outcome. error is set (msg_id empty) when that
// channel's write failed after the pre-flight passed.
message BroadcastChannelEntry {
  string channel = 1;
  string msg_id = 2;
  string created_at = 3;  // RFC3339Nano
  string visible_at = 4;  // RFC3339Nano; set when deferred
  string error = 5;
}

message BroadcastChannelsResponse {
  int32 published = 1;
  int32 failed = 2;
  repeated BroadcastChannelEntry results = 3;
}
