openapi: 3.1.0
info:
  title: YouSaid Rooms API
  version: 1.5.2
  description: Outcome-oriented customer Rooms with product spaces, threads, polls, live Solve Sessions, and appealable moderation. Customer resources are scoped by the signed product identity token; operator endpoints accept a product service API key (Bearer yswld_) with yousaid:rooms:read, yousaid:rooms:write and optionally yousaid:rooms:internal. Every response carries x-request-id, x-yousaid-api-version and x-ratelimit-limit / -remaining / -reset headers; errors use the typed envelope { error { code, message, requestId } }; writes require an Idempotency-Key and replay the stored response (idempotent-replayed true) for a repeated key with the same body. Signals, threads, features, the in-app inbox, and outbound webhooks are described in /openapi-v1.yaml.
servers:
  - url: https://yousaid.dev
security:
  - bearerIdentity: []
paths:
  /api/v1/rooms:
    get:
      operationId: listRooms
      summary: List Rooms visible to the signed product member
      responses:
        "200": { description: Room list, content: { application/json: { schema: { type: object, properties: { rooms: { type: array, items: { $ref: "#/components/schemas/Room" } } } } } } }
    post:
      operationId: createRoom
      summary: Open a Direct, Feature, or Topic Room
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreateRoom" } } } }
      responses:
        "201": { description: Room created }
        "402": { description: Active Room allowance reached }
  /api/v1/rooms/{roomId}:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    get:
      operationId: getRoom
      summary: Read the external Room view, Canvas, ledger, files, and a short-lived realtime token
      responses: { "200": { description: Room detail }, "404": { description: Not visible to this identity } }
    patch:
      operationId: updateRoomNotifications
      summary: Set quiet notification preferences
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [notificationMode, timezone], properties: { notificationMode: { enum: [immediate, digest, lifecycle_only, muted] }, timezone: { type: string } } } } } }
      responses: { "200": { description: Preferences saved } }
  /api/v1/rooms/{roomId}/messages:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    post:
      operationId: postRoomMessage
      summary: Post a customer-visible message or thread reply
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [body, idempotencyKey], properties: { body: { type: string, maxLength: 20000 }, threadRootId: { type: [string, "null"] }, idempotencyKey: { type: string } } } } } }
      responses: { "201": { description: Message accepted }, "422": { description: Public privacy moderation hold }, "429": { description: Rate limited } }
  /api/v1/rooms/{roomId}/messages/{messageId}:
    parameters: [{ $ref: "#/components/parameters/RoomId" }, { name: messageId, in: path, required: true, schema: { type: string } }]
    patch:
      operationId: editOwnRoomMessage
      summary: Edit the signed member's own message
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [body, idempotencyKey], properties: { body: { type: string }, idempotencyKey: { type: string } } } } } }
      responses: { "200": { description: Message edited }, "403": { description: Not the author } }
    delete:
      operationId: deleteOwnRoomMessage
      summary: Tombstone the signed member's own message
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses: { "204": { description: Message removed }, "403": { description: Not the author } }
  /api/v1/rooms/{roomId}/messages/{messageId}/reactions:
    parameters: [{ $ref: "#/components/parameters/RoomId" }, { name: messageId, in: path, required: true, schema: { type: string } }]
    post:
      operationId: reactToRoomMessage
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [emoji, active, idempotencyKey], properties: { emoji: { enum: ["👍", "❤️", "🎯", "👀", "✅"] }, active: { type: boolean }, idempotencyKey: { type: string } } } } } }
      responses: { "200": { description: Reaction state saved } }
  /api/v1/rooms/{roomId}/attachments:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    post:
      operationId: uploadRoomAttachment
      summary: Upload a validated file of at most 10 MB
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
      responses: { "201": { description: File attached }, "422": { description: Unsafe or unsupported file } }
  /api/v1/rooms/{roomId}/attachments/{attachmentId}:
    parameters: [{ $ref: "#/components/parameters/RoomId" }, { name: attachmentId, in: path, required: true, schema: { type: string } }]
    get:
      operationId: downloadRoomAttachment
      summary: Download a permitted attachment with sandboxed, no-sniff headers
      responses: { "200": { description: File body }, "404": { description: File is unavailable or not external } }
  /api/v1/rooms/{roomId}/events:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    get:
      operationId: getRoomEvents
      summary: Read authoritative ordered events after a sequence cursor
      parameters: [{ name: after_seq, in: query, schema: { type: integer, minimum: 0, default: 0 } }, { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 200 } }]
      responses: { "200": { description: "Ordered events after the cursor. When the client is further behind than the replay window, snapshot carries the collapsed state at a sequence and events continue after it; follow nextSequence while hasMore is true." } }
  /api/v1/rooms/{roomId}/catch-up:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    get:
      operationId: catchUpRoom
      summary: Read a bundled count of changes since a sequence
      responses: { "200": { description: Quiet catch-up summary } }
  /api/v1/rooms/{roomId}/join:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    post:
      operationId: joinRoom
      summary: Join a Room under its join policy or consume a one-time invitation
      requestBody: { content: { application/json: { schema: { type: object, properties: { invitationToken: { type: [string, "null"] } } } } } }
      responses: { "200": { description: Membership active }, "403": { description: Invitation or policy denied } }
  /api/v1/rooms/{roomId}/leave:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    post:
      operationId: leaveRoom
      summary: Leave the Room while preserving the event and membership audit history
      responses: { "200": { description: Membership ended } }
  /api/v1/rooms/{roomId}/polls/{pollId}/responses:
    parameters: [{ $ref: "#/components/parameters/RoomId" }, { name: pollId, in: path, required: true, schema: { type: string } }]
    put:
      operationId: respondToRoomPoll
      summary: Create or replace the signed member's response to an open Room poll
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PollResponse" } } } }
      responses: { "200": { description: Response recorded }, "404": { description: Poll is unavailable } }
  /api/v1/rooms/{roomId}/solve-sessions/{sessionId}/join:
    parameters: [{ $ref: "#/components/parameters/RoomId" }, { name: sessionId, in: path, required: true, schema: { type: string } }]
    post:
      operationId: joinSolveSession
      summary: Receive a short-lived participant token for a scheduled or live Cloudflare RealtimeKit session
      requestBody: { content: { application/json: { schema: { type: object, properties: { recordingConsent: { type: boolean, default: false } } } } } }
      responses: { "200": { description: Participant token and session mode }, "409": { description: Recording consent required }, "503": { description: RealtimeKit is not configured } }
  /api/v1/rooms/{roomId}/moderation/reports:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    post:
      operationId: reportRoomContent
      summary: Create a human-review moderation case without changing product priority
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ModerationReport" } } } }
      responses: { "201": { description: Case created with a customer-visible explanation and appeal path } }
  /api/v1/rooms/{roomId}/moderation/{caseId}/appeals:
    parameters: [{ $ref: "#/components/parameters/RoomId" }, { name: caseId, in: path, required: true, schema: { type: string } }]
    post:
      operationId: appealModerationCase
      summary: Submit one appeal for the signed member against an eligible moderation case
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [statement], properties: { statement: { type: string, minLength: 8, maxLength: 4000 } } } } } }
      responses: { "201": { description: Appeal queued for human review }, "403": { description: The member cannot appeal this case } }
  /api/v1/rooms/{roomId}/realtime:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    get:
      operationId: connectRoomRealtime
      summary: Upgrade to a read-only WebSocket using the short-lived token returned by getRoom
      parameters: [{ name: token, in: query, required: true, schema: { type: string } }, { name: after_seq, in: query, schema: { type: integer, minimum: 0 } }]
      responses: { "101": { description: WebSocket upgraded }, "401": { description: Invalid or expired room token } }
  /api/v1/rooms/search:
    get:
      operationId: searchRooms
      summary: Full-text (FTS5) and, when available, semantic search over permitted Rooms. Canonical answers rank first.
      security: [{ bearerIdentity: [] }, { serviceKey: [] }]
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 2, maxLength: 300 } }
        - { name: type, in: query, description: Comma-separated subset of canonical, canvas, message, schema: { type: string } }
        - { name: room_id, in: query, schema: { type: string } }
        - { name: semantic, in: query, schema: { type: boolean, default: true } }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200": { description: Ranked results with the retrieval mode (fts or hybrid), headers: { x-ratelimit-limit: { $ref: "#/components/headers/RateLimitLimit" }, x-ratelimit-remaining: { $ref: "#/components/headers/RateLimitRemaining" }, x-ratelimit-reset: { $ref: "#/components/headers/RateLimitReset" }, x-request-id: { $ref: "#/components/headers/RequestId" } }, content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: "#/components/schemas/SearchHit" } }, mode: { enum: [fts, hybrid] }, semantic: { type: object } } } } } }
        "400": { $ref: "#/components/responses/Error" }
  /api/v1/rooms/{roomId}/canvas:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    get:
      operationId: getRoomCanvas
      summary: Read the Canvas. Customers and default-scope keys receive only fields marked external; yousaid:rooms:internal adds internal fields and the visibility map.
      security: [{ bearerIdentity: [] }, { serviceKey: [yousaid:rooms:read] }]
      responses:
        "200": { description: Canvas, headers: { x-ratelimit-limit: { $ref: "#/components/headers/RateLimitLimit" }, x-ratelimit-remaining: { $ref: "#/components/headers/RateLimitRemaining" }, x-ratelimit-reset: { $ref: "#/components/headers/RateLimitReset" }, x-request-id: { $ref: "#/components/headers/RequestId" } }, content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/Canvas" } } } } } }
        "404": { $ref: "#/components/responses/Error" }
    patch:
      operationId: patchRoomCanvas
      summary: Write a new Canvas version. Optimistic concurrency with expectedVersion; changing fieldVisibility needs yousaid:rooms:internal.
      security: [{ serviceKey: [yousaid:rooms:write] }]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [fields], properties: { expectedVersion: { type: integer, minimum: 1 }, fields: { $ref: "#/components/schemas/CanvasPatch" } } } } } }
      responses:
        "200": { description: New version }
        "409": { $ref: "#/components/responses/Error" }
  /api/v1/rooms/{roomId}/compile:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    post:
      operationId: compileRoom
      summary: Compile a cited PRD from customer-visible evidence into durable storage
      security: [{ serviceKey: [yousaid:rooms:write] }]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses: { "201": { description: Artifact id, source ids and markdown }, "404": { $ref: "#/components/responses/Error" } }
  /api/v1/rooms/{roomId}/promote:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    post:
      operationId: promoteRoomAnswer
      summary: Propose a canonical answer. It enters review; publication is a Tier 3 human approval.
      security: [{ serviceKey: [yousaid:rooms:write] }]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [title, body], properties: { title: { type: string, minLength: 3, maxLength: 180 }, body: { type: string, minLength: 20, maxLength: 30000 }, visibility: { enum: [product_member, public_read, public], default: product_member } } } } } }
      responses: { "201": { description: Canonical answer in review }, "422": { $ref: "#/components/responses/Error" } }
  /api/v1/rooms/{roomId}/members:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    get:
      operationId: listRoomMembers
      summary: Members. Customers never see staff identities or other customers' ids.
      security: [{ bearerIdentity: [] }, { serviceKey: [yousaid:rooms:read] }]
      responses: { "200": { description: Members and count } }
    post:
      operationId: addRoomMember
      summary: Add a customer principal with external visibility. Staff membership is managed in the workspace.
      security: [{ serviceKey: [yousaid:rooms:write] }]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [principalType, principalId], properties: { principalType: { enum: [human, account, cohort] }, principalId: { type: string }, role: { enum: [member, observer], default: member }, notificationMode: { enum: [immediate, digest, lifecycle_only, muted], default: lifecycle_only } } } } } }
      responses: { "201": { description: Member added }, "403": { $ref: "#/components/responses/Error" }, "409": { $ref: "#/components/responses/Error" } }
  /api/v1/rooms/{roomId}/members/{principalId}:
    parameters: [{ $ref: "#/components/parameters/RoomId" }, { name: principalId, in: path, required: true, schema: { type: string } }, { name: principal_type, in: query, schema: { enum: [human, account, cohort], default: human } }]
    delete:
      operationId: removeRoomMember
      summary: Remove an external member. Live sockets close immediately; later frames are re-authorized.
      security: [{ serviceKey: [yousaid:rooms:write] }]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses: { "200": { description: Removed and sockets closed }, "404": { $ref: "#/components/responses/Error" } }
  /api/v1/rooms/{roomId}/agents/{agentAlias}/invoke:
    parameters: [{ $ref: "#/components/parameters/RoomId" }, { name: agentAlias, in: path, required: true, schema: { enum: [support, product, incident] } }]
    post:
      operationId: invokeRoomAgent
      summary: Post a request and let the labeled agent answer from permitted evidence. Consequential requests stage a tiered approval instead of acting.
      security: [{ bearerIdentity: [] }, { serviceKey: [yousaid:rooms:write] }]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [request], properties: { request: { type: string, maxLength: 4000 }, visibility: { enum: [external, internal], default: external } } } } } }
      responses:
        "201": { description: Run id, answer, cited source ids, approval id when staged }
        "409": { description: "AGENT_PAUSED_FOR_HUMAN: the customer asked for a person" }
        "429": { description: "QUOTA_EXCEEDED or RATE_LIMITED" }
  /api/v1/rooms/{roomId}/agent-runs:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    get:
      operationId: listRoomAgentRuns
      summary: Agent runs in this Room, newest first. Customers see only runs they started.
      security: [{ bearerIdentity: [] }, { serviceKey: [yousaid:rooms:read] }]
      parameters: [{ name: status, in: query, schema: { type: string } }, { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Cursor" }]
      responses: { "200": { description: Page of runs with nextCursor } }
  /api/v1/agent-runs/{runId}:
    parameters: [{ name: runId, in: path, required: true, schema: { type: string } }]
    get:
      operationId: getAgentRun
      summary: One agent run with its output, cited sources and approval state
      security: [{ bearerIdentity: [] }, { serviceKey: [yousaid:rooms:read] }]
      responses: { "200": { description: Agent run }, "404": { $ref: "#/components/responses/Error" } }
  /api/v1/agent-runs/{runId}/cancel:
    parameters: [{ name: runId, in: path, required: true, schema: { type: string } }]
    post:
      operationId: cancelAgentRun
      summary: Cancel a run and any approval that has not executed. A cancelled run can never execute later.
      security: [{ bearerIdentity: [] }, { serviceKey: [yousaid:rooms:write] }]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      responses: { "200": { description: Cancelled }, "409": { $ref: "#/components/responses/Error" } }
  /api/v1/approvals:
    get:
      operationId: listApprovals
      summary: Approvals in customer-visible Rooms. Keys see the exact staged preview; customers see only that a review is pending.
      security: [{ bearerIdentity: [] }, { serviceKey: [yousaid:rooms:read] }]
      parameters: [{ name: room_id, in: query, schema: { type: string } }, { name: status, in: query, schema: { type: string, default: pending } }, { $ref: "#/components/parameters/Limit" }, { $ref: "#/components/parameters/Cursor" }]
      responses: { "200": { description: Page of approvals } }
  /api/v1/approvals/{approvalId}/approve:
    parameters: [{ name: approvalId, in: path, required: true, schema: { type: string } }]
    post:
      operationId: approveApproval
      summary: Approve the exact staged payload. Only a signed-in workspace owner or admin (session cookie) may decide; tokens and keys receive HUMAN_APPROVAL_REQUIRED.
      security: [{ workspaceSession: [] }]
      requestBody: { content: { application/json: { schema: { type: object, properties: { note: { type: string, maxLength: 1000 } } } } } }
      responses: { "200": { description: Approved and executed once with a receipt }, "202": { description: Recorded; a second distinct approver is required (Tier 4) }, "403": { $ref: "#/components/responses/Error" } }
  /api/v1/approvals/{approvalId}/reject:
    parameters: [{ name: approvalId, in: path, required: true, schema: { type: string } }]
    post:
      operationId: rejectApproval
      summary: Reject the staged action. Human workspace session only.
      security: [{ workspaceSession: [] }]
      responses: { "200": { description: Rejected; the run is cancelled }, "403": { $ref: "#/components/responses/Error" } }
  /api/v1/rooms/{roomId}/handoff:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    get:
      operationId: getHumanHandoff
      summary: The signed customer's current request for a person, if any
      security: [{ bearerIdentity: [] }]
      responses: { "200": { description: Handoff or null } }
    post:
      operationId: requestHuman
      summary: Ask for a person. Acknowledged immediately; pauses agent replies to this customer; notifies the Room owner; starts the first-response SLA.
      security: [{ bearerIdentity: [] }]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { content: { application/json: { schema: { type: object, properties: { reason: { type: string, maxLength: 1000 } } } } } }
      responses: { "201": { description: Request recorded, content: { application/json: { schema: { type: object, properties: { data: { $ref: "#/components/schemas/HumanHandoff" } } } } } }, "200": { description: An open request already exists } }
    delete:
      operationId: withdrawHumanHandoff
      summary: Withdraw the request; agent replies resume
      security: [{ bearerIdentity: [] }]
      responses: { "200": { description: Withdrawn }, "404": { $ref: "#/components/responses/Error" } }
  /api/v1/rooms/{roomId}/jobs:
    parameters: [{ $ref: "#/components/parameters/RoomId" }]
    post:
      operationId: startRoomJob
      summary: Start a durable job (Cloudflare Workflow with queue fallback) - compile_export, release_followup, or cohort_invite (needs an approved rooms.invite_cohort action)
      security: [{ serviceKey: [yousaid:rooms:write] }]
      parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [kind], properties: { kind: { enum: [compile_export, release_followup, cohort_invite] }, params: { type: object } } } } } }
      responses: { "202": { description: Job accepted with step plan }, "403": { $ref: "#/components/responses/Error" } }
  /api/v1/rooms/{roomId}/jobs/{jobId}:
    parameters: [{ $ref: "#/components/parameters/RoomId" }, { name: jobId, in: path, required: true, schema: { type: string } }]
    get:
      operationId: getRoomJob
      summary: Step-level progress merged with the Workflow engine status
      security: [{ serviceKey: [yousaid:rooms:read] }]
      responses: { "200": { description: Job status }, "404": { $ref: "#/components/responses/Error" } }
components:
  securitySchemes:
    bearerIdentity: { type: http, scheme: bearer, bearerFormat: YouSaid signed identity JWT }
    serviceKey: { type: http, scheme: bearer, bearerFormat: "yswld_ product service API key" }
    workspaceSession: { type: apiKey, in: cookie, name: better-auth.session_token, description: Signed-in YouSaid workspace human }
  parameters:
    RoomId: { name: roomId, in: path, required: true, schema: { type: string, pattern: "^room_" } }
    IdempotencyKey: { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 8, maxLength: 160 } }
    Limit: { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100 } }
    Cursor: { name: cursor, in: query, schema: { type: string } }
  headers:
    RateLimitLimit: { description: Requests allowed in the current one-minute window, schema: { type: integer } }
    RateLimitRemaining: { description: Requests left in the window, schema: { type: integer } }
    RateLimitReset: { description: Unix time when the window resets, schema: { type: integer } }
    RequestId: { description: Correlation id for support and audit, schema: { type: string } }
  responses:
    Error:
      description: Typed error envelope
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
  schemas:
    Room:
      type: object
      required: [id, title, template_key, purpose, outcome_definition, visibility, status, last_event_seq]
      properties:
        id: { type: string }
        title: { type: string }
        template_key: { enum: [direct, account, incident, feature, topic, beta, research] }
        channel_kind: { enum: [outcome, forum, announcements, office_hours] }
        space_id: { type: [string, "null"] }
        purpose: { type: string }
        outcome_definition: { type: string }
        visibility: { enum: [private_direct, private_account, private_cohort, product_member, public_read, public] }
        status: { enum: [open, active, decision_pending, in_progress, verification, resolved, canonicalized, archived] }
        last_event_seq: { type: integer }
    CreateRoom:
      type: object
      required: [title, idempotencyKey]
      properties:
        template: { enum: [direct, feature, topic, office_hours], default: direct }
        title: { type: string, minLength: 3, maxLength: 140 }
        purpose: { type: string, maxLength: 600 }
        outcome: { type: string, maxLength: 600 }
        idempotencyKey: { type: string, minLength: 8, maxLength: 160 }
    PollResponse:
      type: object
      properties:
        optionIds: { type: array, maxItems: 20, items: { type: string } }
        answer: { type: [string, "null"], maxLength: 4000 }
        ranking: { type: array, maxItems: 20, items: { type: string } }
    ModerationReport:
      type: object
      required: [category, reason]
      properties:
        messageId: { type: [string, "null"] }
        category: { enum: [spam, harassment, unsafe, impersonation, off_topic, duplicate, other] }
        reason: { type: string, minLength: 8, maxLength: 2000 }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, requestId]
          properties:
            code: { type: string, examples: [VALIDATION_FAILED, NOT_FOUND, INSUFFICIENT_SCOPE, RATE_LIMITED, IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_KEY_REUSED, CANVAS_VERSION_CONFLICT, HUMAN_APPROVAL_REQUIRED, AGENT_PAUSED_FOR_HUMAN, QUOTA_EXCEEDED] }
            message: { type: string }
            requestId: { type: string }
            details: {}
    Canvas:
      type: object
      properties:
        roomId: { type: string }
        version: { type: integer }
        updatedAt: { type: string, format: date-time }
        content: { type: object, additionalProperties: true }
        fieldVisibility: { type: object, additionalProperties: { enum: [external, internal] } }
    CanvasPatch:
      type: object
      additionalProperties: false
      properties:
        purpose: { type: string }
        outcome: { type: string }
        currentState: { type: string }
        confirmedFacts: { type: array, items: { type: string } }
        hypotheses: { type: array, items: { type: string } }
        openQuestions: { type: array, items: { type: string } }
        affected: { type: array, items: { type: string } }
        actions: { type: array, items: { type: string } }
        release: { type: [string, "null"] }
        verification: { type: object, properties: { attempted: { type: boolean }, result: { type: [string, "null"] } } }
        canonicalAnswer: { type: [string, "null"] }
        fieldVisibility: { type: object, additionalProperties: { enum: [external, internal] } }
    HumanHandoff:
      type: object
      properties:
        id: { type: string }
        status: { enum: [open, acknowledged, resolved, cancelled] }
        requestedAt: { type: string, format: date-time }
        respondBy: { type: string, format: date-time }
        acknowledged: { type: boolean }
        agentRepliesPaused: { type: boolean }
        message: { type: string }
    SearchHit:
      type: object
      properties:
        type: { enum: [canonical, canvas, message] }
        id: { type: string }
        roomId: { type: string }
        roomTitle: { type: string }
        excerpt: { type: string }
        score: { type: number }
        matchedBy: { enum: [fts, semantic] }
