@0xb9a0e7d3c1f24680;

# Wirken IPC Protocol
#
# This schema defines the wire format between the gateway core and
# adapter processes. All communication goes over Unix domain sockets
# using Cap'n Proto serialization.
#
# Schema evolution: new fields can be added to any struct without
# breaking existing adapters. Fields must never be removed or reordered.

# --- Authentication ---

struct AuthChallenge {
  nonce @0 :Data;
  # 32-byte random nonce generated by the gateway.
  # The adapter must sign this with its Ed25519 private key.
}

struct AuthResponse {
  publicKey @0 :Data;
  # 32-byte Ed25519 public key of the adapter.
  signature @1 :Data;
  # 64-byte Ed25519 signature of the challenge nonce.
  adapterId @2 :Text;
  # Identifier for this adapter (e.g., "telegram", "discord").
}

struct AuthResult {
  accepted @0 :Bool;
  reason @1 :Text;
  # Human-readable reason if rejected.
}

# --- Heartbeat ---

struct Heartbeat {
  seq @0 :UInt64;
  # Monotonically increasing sequence number.
}

# --- Messages ---

struct InboundMessage {
  # Message from a channel user, delivered by an adapter to the gateway.
  id @0 :Text;
  # Unique message ID (adapter-assigned).
  senderId @1 :Text;
  # Sender identifier (platform-specific: phone number, user ID, etc.).
  senderName @2 :Text;
  # Display name of the sender.
  channel @3 :Text;
  # Channel identifier (e.g., "telegram", "discord").
  conversationId @4 :Text;
  # Conversation/chat/group ID.
  text @5 :Text;
  # Message text content.
  timestamp @6 :Int64;
  # Unix timestamp in milliseconds.
  isGroup @7 :Bool;
  # Whether this message is from a group conversation.
  replyToId @8 :Text;
  # ID of the message this is replying to (empty if not a reply).
  metadata @9 :Text;
  # JSON-encoded platform-specific metadata.
}

struct OutboundMessage {
  # Message from the gateway to be sent by an adapter.
  conversationId @0 :Text;
  # Target conversation/chat/group ID.
  text @1 :Text;
  # Message text content.
  replyToId @2 :Text;
  # ID of the message to reply to (empty for new message).
  metadata @3 :Text;
  # JSON-encoded platform-specific metadata.
}

struct OutboundResult {
  # Result of sending an outbound message.
  success @0 :Bool;
  messageId @1 :Text;
  # Platform-assigned message ID if successful.
  error @2 :Text;
  # Error description if failed.
}

# --- Session ---

struct SessionQuery {
  sessionId @0 :Text;
}

struct SessionView {
  sessionId @0 :Text;
  channel @1 :Text;
  conversationId @2 :Text;
  messageCount @3 :UInt64;
  lastActivityMs @4 :Int64;
  # Unix timestamp in milliseconds of last activity.
}

# --- LLM streaming ---

struct LlmChunk {
  union {
    data @0 :Text;
    # A chunk of streaming LLM response text.
    error @1 :Text;
    # An error that terminated the stream.
    done @2 :Void;
    # Signal that the stream is complete.
  }
}

# --- Hooks ---
#
# Hook handshake reuses the same Ed25519 wire shape as the adapter
# handshake but binds the signature under a distinct domain
# separator (see crates/ipc/src/auth.rs HOOK_HANDSHAKE_DOMAIN) so an
# adapter signature can never replay against the hook acceptor and
# vice versa. The wire-level structs are separate so the gateway can
# route an inbound connection by which response variant the peer
# sent first.

struct HookAuthResponse {
  publicKey @0 :Data;
  # 32-byte Ed25519 public key of the hook process.
  signature @1 :Data;
  # 64-byte Ed25519 signature of the challenge nonce.
  hookId @2 :Text;
  # Identifier for this hook (e.g., "policy-eval", "ralph-loop").
  hookType @3 :Text;
  # "observe", "veto", or "egress". The gateway routes the
  # post-handshake connection by this label; an unknown label is a
  # handshake reject.
}

struct SessionLogTail {
  # Hook → Gateway. Observe-hook pull request for events with
  # sequence >= sinceSeq for the named session.
  sessionId @0 :Text;
  sinceSeq @1 :UInt64;
  # Caller-supplied cursor. Hook advances on its own after each
  # successful response.
  maxRows @2 :UInt32;
  # Soft cap on the response size so a hook coming back from a long
  # downtime does not get an unboundedly large batch.
}

struct SessionLogTailEvent {
  seq @0 :UInt64;
  # Per-session monotonic sequence number, copied from the session
  # log so the hook's cursor advances against the same numbers the
  # gateway sees.
  payload @1 :Text;
  # JSON-serialized SessionEvent. The hook deserializes against its
  # own copy of the SessionEvent enum; the JSON wire keeps the
  # capnp schema independent of audit-side variant churn.
}

struct SessionLogTailResponse {
  # Gateway → Hook. Batch response to a SessionLogTail.
  events @0 :List(SessionLogTailEvent);
  nextSeq @1 :UInt64;
  # The sequence the hook should pass as `sinceSeq` on the next
  # SessionLogTail to receive only newer rows. If `events` is empty
  # this equals the request's `sinceSeq`.
}

struct VetoRequest {
  # Gateway → Veto hook. Pre-dispatch tool call. The hook responds
  # with a VetoResponse keyed by requestId.
  #
  # Intentionally narrow: tool name, arguments, and the session that
  # surrounds the call. Per-call agent identity is not on the wire;
  # routing must remain handshake-derived (see
  # tests/no_payload_routing.rs). The gateway logs the local agent
  # id onto the SessionEvent::HookDispatched row at dispatch time,
  # so the audit chain still carries the attribution even though the
  # hook never sees an agent_id field.
  requestId @0 :Text;
  toolName @1 :Text;
  arguments @2 :Text;
  # JSON-encoded tool arguments, as the agent received them from the
  # LLM and after Wirken's per-skill / tier permission gate already
  # passed.
  sessionId @3 :Text;
}

struct VetoResponse {
  requestId @0 :Text;
  union {
    allow @1 :Void;
    deny @2 :Text;
    # Operator-readable reason. Recorded on the audit chain and
    # surfaced as the tool call's failure message.
  }
}

struct EgressRequest {
  # Gateway -> Egress hook. Post-execution mediation of one tool
  # call's output bytes before they enter the LLM conversation. The
  # hook responds with an EgressResponse keyed by requestId.
  #
  # `output` is raw bytes because tool outputs are not utf-8
  # guaranteed (binary content, malformed text from a misbehaving
  # tool). The runtime converts via String::from_utf8_lossy on the
  # final working copy after every hook has had its say; the chain
  # row records the lossy form so the conversation surface stays
  # String-typed end to end.
  requestId @0 :Text;
  toolName @1 :Text;
  output @2 :Data;
  sessionId @3 :Text;
}

struct EgressResponse {
  requestId @0 :Text;
  union {
    allow @1 :Void;
    # Pass the bytes through unchanged. The next hook in the
    # pipeline sees the same working copy.
    replace @2 :Data;
    # Substitute these bytes for the working copy. The next hook
    # sees the new bytes; on completion, the runtime feeds the
    # final working copy into add_tool_result and the ToolResult
    # audit row. The original bytes are not on the chain; the
    # paired ToolOutputRedacted row carries original_sha256 only.
    refuse @3 :Text;
    # Refuse the tool result outright. Operator-readable reason.
    # Short-circuits the pipeline. The runtime emits a refusal
    # placeholder as the tool's output so the LLM sees that the
    # call did not produce usable bytes.
  }
}

# --- Channel-adapter approval ---
#
# Out-of-band approval for daemon-mode `NeedsApproval` requests
# routed to a channel adapter. The gateway-side channel gate (e.g.
# `TelegramApprovalGate`, `SignalApprovalGate`) registers an entry
# in the same `PendingApprovalQueue` the CLI surface uses, then
# sends an `ApprovalRequest` frame to the named adapter. The
# adapter renders the surface-native UX (Telegram inline keyboard,
# Signal text-command prompt, …) in the configured approval
# conversation; an operator action comes back as
# `ApprovalDecision`. On delivery failure the adapter emits
# `ApprovalRequestFailed`. The gateway authorizes the actor
# against `approver_registry` before resolving the queue.

struct ApprovalRequest {
  # Gateway → adapter. Adapter renders the surface-native approval
  # UX in the configured conversation. Telegram: inline keyboard
  # with Approve / Deny buttons; `callback_data` encoding is
  # `req:<requestId>:allow` or `req:<requestId>:deny` so the
  # adapter parses the decision before building the response
  # frame (no Unknown variant on ApprovalDecisionKind). Signal:
  # text-command prompt of the form `!approve <prefix>` /
  # `!deny <prefix> [reason]`, prefix is the first 8 hex chars of
  # `requestId` and the adapter resolves prefix → requestId
  # locally before responding.
  requestId @0 :Text;
  toolName @1 :Text;
  actionKey @2 :Text;
  requestedTier @3 :Text;
  # `triggeringAgent` rather than `agentId`: the latter is on the
  # `tests/no_payload_routing.rs` banned list (routing must come
  # from handshake, not payload). This field is the
  # operator-display label for "which agent is asking" and the
  # adapter renders it in the approval message; it is not used
  # for routing.
  triggeringAgent @4 :Text;
  # Inbound user message that triggered the agent's tool call.
  # Operator-facing context. Empty when the call has no inbound
  # trigger (subagent recursion, cron, system-driven).
  triggerMessage @5 :Text;
  # Adapter-native conversation identifier to render the approval
  # message in. The gateway reads this from `approver_registry` at
  # `request_approval` time; the adapter does not consult registry
  # state and treats the value as authoritative. Adapter-native
  # encoding: Telegram serializes its `i64` chat_id as a decimal
  # string; Signal carries the base64 group_id (or E.164 phone /
  # ACI UUID for 1:1 DM approval channels); other adapters
  # whatever their conversation primitive is. Text rather than the
  # original Int64 because Signal and other non-Telegram surfaces
  # do not have numeric conversation ids.
  targetConversationId @6 :Text;
}

struct ApprovalDecisionKind {
  union {
    allow @0 :Void;
    deny @1 :Text;
    # Operator-supplied reason for the denial, or empty string
    # when the surface did not capture one. Surfaces that do not
    # collect a reason (Telegram inline keyboard has no
    # ForceReply pattern wired today) write empty; surfaces that
    # do (Signal text command's trailing text after the prefix)
    # write the typed reason. Empty maps to `denial_reason:
    # None` on the gateway side; non-empty to `Some(reason)`.
  }
}

struct ApprovalDecision {
  # Adapter → gateway. An operator action on the approval surface
  # (Telegram inline-keyboard press, Signal text command, …)
  # arrived. The gateway validates `actorUserId` against
  # `approver_registry::verify(adapter_id, user_id)` BEFORE
  # resolving the queue entry. An unauthorized action is silently
  # dropped at the gateway (queue stays open until timeout); the
  # adapter does not consult registry state.
  requestId @0 :Text;
  decision @1 :ApprovalDecisionKind;
  # Adapter-native actor identifier. Authorized against
  # `approver_registry`. Telegram serializes its `i64` user id as
  # a decimal string; Signal carries the sender's ACI UUID
  # (preferred) or E.164 phone fallback; other adapters whatever
  # their stable per-user primitive is. Text rather than Int64
  # because most non-Telegram platforms identify users by string.
  actorUserId @2 :Text;
  # Display name or username for the audit row's `approved_by` /
  # actor field. Falls back to the user id when the display is
  # empty.
  actorDisplay @3 :Text;
}

struct ApprovalRequestFailed {
  # Adapter → gateway. Approval message could not be delivered.
  # The gateway resolves the queue entry as
  # `PendingDecision::Timeout` and the audit row records
  # `denial_reason: Some("approval delivery failed: <reason>")`.
  # Distinct frame (not an error response) because the failure is
  # asynchronous to the `request_approval` await.
  requestId @0 :Text;
  # Snake_case reason label. Stable strings for SIEM grouping:
  # `no_approval_chat_configured`, `chat_not_accessible`,
  # `telegram_api_error`. Operators see the label in the audit
  # row; the longer error detail goes to gateway logs.
  reason @1 :Text;
}

# --- Top-level frame ---
#
# Every message on the wire is a Frame. The tag determines
# which adapter/gateway handler processes it.

struct Frame {
  union {
    # Auth handshake (gateway → adapter, adapter → gateway)
    authChallenge @0 :AuthChallenge;
    authResponse @1 :AuthResponse;
    authResult @2 :AuthResult;

    # Keepalive
    heartbeat @3 :Heartbeat;

    # Adapter → Gateway
    inbound @4 :InboundMessage;
    sessionQuery @5 :SessionQuery;

    # Gateway → Adapter
    outbound @6 :OutboundMessage;
    outboundResult @7 :OutboundResult;
    sessionView @8 :SessionView;

    # LLM streaming (gateway → agent, bidirectional)
    llmChunk @9 :LlmChunk;

    # Hook handshake. authChallenge / authResult are reused; the
    # response shape is distinct so the gateway routes by which
    # response variant the peer sent.
    hookAuthResponse @10 :HookAuthResponse;

    # Observe-hook pull cursor (hook → gateway, gateway → hook).
    sessionLogTail @11 :SessionLogTail;
    sessionLogTailResponse @12 :SessionLogTailResponse;

    # Veto-hook synchronous request/response.
    vetoRequest @13 :VetoRequest;
    vetoResponse @14 :VetoResponse;

    # Channel-adapter approval (Telegram is the first surface;
    # other adapters inherit the wire shape unchanged).
    approvalRequest @15 :ApprovalRequest;
    approvalDecision @16 :ApprovalDecision;
    approvalRequestFailed @17 :ApprovalRequestFailed;

    # Egress-hook synchronous request/response. Post-execution
    # mediation of one tool call's output bytes before they enter
    # the LLM conversation. Twin of veto on the return path.
    egressRequest @18 :EgressRequest;
    egressResponse @19 :EgressResponse;
  }
}
