{
  "openapi": "3.1.1",
  "info": {
    "title": "DutyGraph Companion Mobile API",
    "version": "1.0.0",
    "description": "Contract for the DutyGraph Companion mobile client. This document pins request and response shapes so a React Native app can generate types and QA can write contract tests. It does not implement handlers.\n\nThe base path is /api/mobile/v1 on the same DutyGraph origin as the advisor application. PostgreSQL remains the only business database. These routes are wrappers over the same invitation and request records as the browser participant flows. They do not replace GET/POST /api/invitations/{token}/team, team transcription, or kickoff links.\n\nAuthentication is a participant bearer session. It is separate from the browser dg_session cookie and the X-CSRF-Token header. The server derives tenant, company, person, and device from the session. The role is participant_mobile. The client must not send a company id or person id as authority.\n\nA target that belongs to a different tenant or person returns 403 PERSON_FORBIDDEN. A stale expectedVersion returns 409 VERSION_CONFLICT. A content hash that does not match the saved request or the saved proposal returns 409 HASH_CONFLICT. An invitation token that is missing, already used, revoked, or expired returns 410 INVITATION_EXPIRED, the same outcome as browser team and kickoff links. A closed or past-due request returns 410 REQUEST_CLOSED.\n\nCommands that change state require Idempotency-Key, at most 128 characters. The same actor, path, and key with the same canonical body return the saved result. The same key with a different body returns 409 IDEMPOTENCY_CONFLICT.\n\nTranscription and understanding results are proposals. confirmationState is always proposed. Submit requires participantConfirmed true and the proposal hash. Submit records the participant's words and their confirmation of their understanding. It does not accept a Task Card as company truth, grant authority, or skip advisor review. Task field names match the existing participant card review schema. This contract does not define a second Task Card schema.\n\nProvider credentials never appear in this document or in client responses. Understanding calls are specified with provider retention off (store false).\n\nOmitted until a later contract revision: PUT /me/push-token, POST /requests/{requestId}/transcription-stream-ticket, GET/POST /task-cards routes, and POST /updates.",
    "x-dutygraph-status": "contract-only"
  },
  "servers": [
    {
      "url": "http://localhost:4317/api/mobile/v1",
      "description": "Local DutyGraph server. Same process as the advisor app."
    },
    {
      "url": "https://dutygraph.com/api/mobile/v1",
      "description": "Hosted DutyGraph origin."
    }
  ],
  "tags": [
    { "name": "Enrollment", "description": "Exchange a single-use invitation token for a device session." },
    { "name": "Session", "description": "Refresh and revoke participant mobile sessions." },
    { "name": "Profile", "description": "Identity screen for the signed-in participant." },
    { "name": "Requests", "description": "Needs-you inbox and question screen." },
    { "name": "Transcription", "description": "Server-side speech-to-text. Schema only." },
    { "name": "Understanding", "description": "Proposed understanding. Schema only. Never auto-confirmed." }
  ],
  "security": [{ "participantBearer": [] }],
  "paths": {
    "/enroll": {
      "post": {
        "tags": ["Enrollment"],
        "operationId": "enrollParticipant",
        "summary": "Exchange an invitation token for a participant mobile session",
        "description": "Send the invitation token in the body, not in the URL. The token is the same 64-character hex value used by browser team and kickoff links. The server hashes it the same way and does not echo it back.\n\nA missing, used, revoked, or expired token returns 410 INVITATION_EXPIRED. A token whose request is closed or past due returns 410 REQUEST_CLOSED. A token that does not grant this participant the matching team or kickoff request returns 403 PERSON_FORBIDDEN. A malformed token returns 422 VALIDATION_ERROR.\n\nSuccess consumes the invitation. A replay of the same token returns 410. The client must store the session from the 201 response. If that response is lost, the participant needs a replacement invitation from the advisor. Enroll does not take Idempotency-Key, because the invitation is already single-use.\n\nNo bearer token is required. This call does not create a browser cookie session and does not return a CSRF token.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/EnrollRequest" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "New participant mobile session. Store both tokens. The access token is sent as Authorization Bearer on later calls.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/RequestId" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ParticipantMobileSession" }
              }
            }
          },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "410": { "$ref": "#/components/responses/InvitationGone" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/session/refresh": {
      "post": {
        "tags": ["Session"],
        "operationId": "refreshParticipantSession",
        "summary": "Rotate the refresh token and issue a new access token",
        "description": "Send the current refresh token in the body. Do not send the access token. A successful call revokes the presented refresh token and returns a new pair.\n\nIdempotency-Key is required. The same key and the same refresh token return the saved rotated session, with idempotentReplay true. Use that replay when the 200 response was lost. A second refresh that presents an already rotated token with a new Idempotency-Key returns 401 REFRESH_REPLAY. An expired or revoked refresh token returns 401 SESSION_EXPIRED. The same key with a different body returns 409 IDEMPOTENCY_CONFLICT.",
        "security": [],
        "parameters": [
          { "$ref": "#/components/parameters/IdempotencyKey" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/RefreshRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rotated session. Replace the stored access token and refresh token with this body.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/RequestId" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ParticipantMobileSession" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "409": { "$ref": "#/components/responses/IdempotencyConflict" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/session/logout": {
      "post": {
        "tags": ["Session"],
        "operationId": "logoutParticipantSession",
        "summary": "Revoke the current participant device session",
        "description": "Revokes the device session identified by the bearer access token. Idempotency-Key is required. The first success returns 204 and the saved result is an empty revocation. Replaying that same key returns 204 even after the access token is revoked. A different key after revocation returns 401 SESSION_REVOKED. This call does not revoke other devices.",
        "parameters": [
          { "$ref": "#/components/parameters/IdempotencyKey" }
        ],
        "responses": {
          "204": {
            "description": "Session revoked, or this Idempotency-Key already revoked it.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/RequestId" }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "409": { "$ref": "#/components/responses/IdempotencyConflict" }
        }
      }
    },
    "/me": {
      "get": {
        "tags": ["Profile"],
        "operationId": "getParticipantProfile",
        "summary": "Read the signed-in participant profile",
        "description": "Returns the minimal profile for the identity screen. personId and companyId are echoes of the server session. They are not credentials. A session whose person is missing or outside the session tenant returns 403 PERSON_FORBIDDEN. A missing or expired access token returns 401 SESSION_EXPIRED.",
        "responses": {
          "200": {
            "description": "Profile for the session participant.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/RequestId" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ParticipantProfile" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" }
        }
      }
    },
    "/requests": {
      "get": {
        "tags": ["Requests"],
        "operationId": "listParticipantRequests",
        "summary": "List requests for the Needs-you inbox",
        "description": "Returns requests assigned to the session person. The default inbox is needs-you: state sent and not past due. scope=all also includes requests this person already returned or that are closed. The server ignores any client-supplied company or person filter. A session that cannot be scoped to one person returns 403 PERSON_FORBIDDEN. The list does not include another person's requests.",
        "parameters": [
          {
            "name": "scope",
            "in": "query",
            "required": false,
            "description": "needs-you is the inbox. all includes closed and returned requests for this person only.",
            "schema": {
              "type": "string",
              "enum": ["needs-you", "all"],
              "default": "needs-you"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Assigned requests. The array is empty when the inbox has no open requests.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/RequestId" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ParticipantRequestList" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "422": { "$ref": "#/components/responses/ValidationError" }
        }
      }
    },
    "/requests/{requestId}": {
      "get": {
        "tags": ["Requests"],
        "operationId": "getParticipantRequest",
        "summary": "Read one request for the question screen",
        "description": "Returns the question text, version, and contentHash the client must send back on transcription, understanding, and submit. A request assigned to a different person or tenant returns 403 PERSON_FORBIDDEN. An unknown id inside this session's company returns 404 REQUEST_NOT_FOUND. A closed or past-due request still returns 200 so the screen can explain that it is closed; needsYou is false. Submit and provider calls then return 410 REQUEST_CLOSED.",
        "parameters": [
          { "$ref": "#/components/parameters/RequestId" }
        ],
        "responses": {
          "200": {
            "description": "Question screen for this participant.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/RequestId" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ParticipantRequestDetail" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/requests/{requestId}/transcriptions": {
      "post": {
        "tags": ["Transcription"],
        "operationId": "createTranscription",
        "summary": "Transcribe audio on the server",
        "description": "Server-side speech-to-text only. The pinned model is grok-voice-transcribe-2.0. The provider credential stays on the server.\n\nSend either a multipart audio clip or JSON that points at an audio asset already stored for this request. Send one of those bodies, not both. The clip limit is 25 MB. Allowed types are audio/webm, audio/mp4, audio/ogg, audio/wav, and audio/mpeg. consent must be true.\n\nexpectedVersion and contentHash must match the request detail. A mismatch returns 409 VERSION_CONFLICT or 409 HASH_CONFLICT. A different person or tenant returns 403 PERSON_FORBIDDEN. A closed request returns 410 REQUEST_CLOSED. A clip that is too large returns 413. An unsupported type returns 422 AUDIO_TYPE. A missing provider configuration returns 503 TRANSCRIPTION_NOT_CONFIGURED. The daily limit returns 429 TRANSCRIPTION_LIMIT.\n\nIdempotency-Key is required. The same key and body return the saved job, with idempotentReplay true.\n\nThe transcript is a proposal. reviewRequired is true. The participant must read it before submit. A failed or unknown job does not delete the stored audio. The participant can type an answer instead.",
        "parameters": [
          { "$ref": "#/components/parameters/RequestId" },
          { "$ref": "#/components/parameters/IdempotencyKey" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": { "$ref": "#/components/schemas/TranscriptionUpload" },
              "encoding": {
                "meta": { "contentType": "application/json" }
              }
            },
            "application/json": {
              "schema": { "$ref": "#/components/schemas/TranscriptionFromAsset" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved transcription job. text is present only when job.state is complete. A failed job still returns 200 so the client can show job.message and keep the audio.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/RequestId" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/TranscriptionResult" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/VersionOrHashConflict" },
          "410": { "$ref": "#/components/responses/RequestClosed" },
          "413": { "$ref": "#/components/responses/PayloadTooLarge" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/requests/{requestId}/understanding": {
      "post": {
        "tags": ["Understanding"],
        "operationId": "createUnderstanding",
        "summary": "Propose a strict-schema understanding of the participant's words",
        "description": "Turns participant-reviewed words into a proposed understanding. The pinned model is grok-4.7. The call is specified with store false, no web search, and no file store. The provider credential stays on the server.\n\nsourceText must be the participant's typed answer or the transcript they have already read. The server must not replace those words with an unreviewed rewrite. When source is reviewed_transcript, transcriptJobId is required and must be a complete transcription job for this request.\n\nexpectedVersion and contentHash must match the request. A mismatch returns 409 VERSION_CONFLICT or 409 HASH_CONFLICT. A different person or tenant returns 403 PERSON_FORBIDDEN. A closed request returns 410 REQUEST_CLOSED. A missing provider configuration returns 503 UNDERSTANDING_NOT_CONFIGURED.\n\nIdempotency-Key is required. The same key and body return the saved proposal.\n\nconfirmationState is always proposed. The proposal is not company truth, not participant confirmation, and not an accepted Task Card. Missing steps stay in gaps. The proposal does not invent authority from a job title. Field names match the existing participant card review schema, without the participant decision. Submit is a separate call.",
        "parameters": [
          { "$ref": "#/components/parameters/RequestId" },
          { "$ref": "#/components/parameters/IdempotencyKey" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/UnderstandingRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Proposed understanding. confirmationState is proposed. Show it to the participant before submit.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/RequestId" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ProposedUnderstanding" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/VersionOrHashConflict" },
          "410": { "$ref": "#/components/responses/RequestClosed" },
          "422": { "$ref": "#/components/responses/ValidationError" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/requests/{requestId}/submit": {
      "post": {
        "tags": ["Requests"],
        "operationId": "submitParticipantRequest",
        "summary": "Submit the participant's answer and confirmed understanding",
        "description": "Saves the participant's words, the confirmed understanding, and attestation hashes. expectedVersion must equal the request version. contentHash must equal the request contentHash from the detail response. A mismatch returns 409 VERSION_CONFLICT or 409 HASH_CONFLICT. Review the current detail after a 409. Do not retry the old body with a new Idempotency-Key.\n\nparticipantConfirmed must be true. understanding.contentHash must equal the contentHash of the proposed understanding the participant reviewed. attestation.requestContentHash must equal contentHash. attestation.understandingHash must equal understanding.contentHash. A body that disagrees with itself returns 422 VALIDATION_ERROR. A hash that does not match the saved request or the saved proposal returns 409 HASH_CONFLICT.\n\nEach task uses the existing participant card fields plus the participant's decision: correct, not_mine, or unsure. The decision is the participant's. The model does not set it. This submit does not mark Task Cards canonical and does not grant authority.\n\nIdempotency-Key is required. The same key and the same body return the saved SubmitResult with idempotentReplay true and do not create a second response. The same key with a different body returns 409 IDEMPOTENCY_CONFLICT.\n\nA different person or tenant returns 403 PERSON_FORBIDDEN. A closed or past-due request returns 410 REQUEST_CLOSED.\n\nThe participant's words are the committed result. A later reconciliation job may be queued only after that save. Reconciliation failure must not remove the submitted words. reconciliation.state not_requested means no follow-up job was queued.",
        "parameters": [
          { "$ref": "#/components/parameters/RequestId" },
          { "$ref": "#/components/parameters/IdempotencyKey" }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/SubmitRequest" },
              "examples": {
                "confirmed": {
                  "summary": "Fictional confirmed answer",
                  "value": {
                    "expectedVersion": 3,
                    "contentHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                    "participantConfirmed": true,
                    "text": "I check the example inbox each morning and send the summary to the team lead.",
                    "source": "reviewed_transcript",
                    "transcriptJobId": "00000000-0000-4000-8000-000000000010",
                    "understanding": {
                      "contentHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                      "tasks": [
                        {
                          "title": "Send the morning inbox summary",
                          "duty": "Watch the shared inbox",
                          "inputs": "Overnight messages in the example inbox",
                          "instructions": "Read the new messages and write a short summary.",
                          "output": "Morning summary",
                          "handoff": "Team lead",
                          "software": "Example mail",
                          "decision": "correct"
                        }
                      ]
                    },
                    "attestation": {
                      "requestContentHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                      "understandingHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                      "transcriptHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "First successful submit. requestState is returned. idempotentReplay is false.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/RequestId" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SubmitResult" }
              }
            }
          },
          "200": {
            "description": "Saved result for this Idempotency-Key. idempotentReplay is true. No second response was written.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/RequestId" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SubmitResult" }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": {
            "description": "VERSION_CONFLICT when expectedVersion is stale. HASH_CONFLICT when contentHash or understandingHash does not match the saved request or proposal. IDEMPOTENCY_CONFLICT when this Idempotency-Key was already used with a different body. retryable is false. Reload the request before a new key.",
            "headers": {
              "X-Request-Id": { "$ref": "#/components/headers/RequestId" }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ApiError" },
                "examples": {
                  "version": {
                    "value": {
                      "code": "VERSION_CONFLICT",
                      "message": "The request changed. Reload before submitting.",
                      "retryable": false,
                      "requestId": "00000000-0000-4000-8000-000000000001"
                    }
                  },
                  "hash": {
                    "value": {
                      "code": "HASH_CONFLICT",
                      "message": "The request or understanding hash does not match the saved record.",
                      "retryable": false,
                      "requestId": "00000000-0000-4000-8000-000000000001"
                    }
                  },
                  "idempotency": {
                    "value": {
                      "code": "IDEMPOTENCY_CONFLICT",
                      "message": "This command key was already used with different content.",
                      "retryable": false,
                      "requestId": "00000000-0000-4000-8000-000000000001"
                    }
                  }
                }
              }
            }
          },
          "410": { "$ref": "#/components/responses/RequestClosed" },
          "422": { "$ref": "#/components/responses/ValidationError" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "participantBearer": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "opaque",
        "description": "Participant mobile access token from enroll or refresh. Send it as Authorization: Bearer. This is not the browser dg_session cookie, not X-CSRF-Token, and not a provider credential. The server loads tenant, company, person, device, and role participant_mobile from this token."
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "description": "1 to 128 characters. The same actor, path, and key with the same canonical body return the saved result. A different body for that key returns 409 IDEMPOTENCY_CONFLICT.",
        "schema": { "type": "string", "minLength": 1, "maxLength": 128 }
      },
      "RequestId": {
        "name": "requestId",
        "in": "path",
        "required": true,
        "description": "Request record id assigned to the session participant.",
        "schema": { "$ref": "#/components/schemas/Uuid" }
      }
    },
    "headers": {
      "RequestId": {
        "description": "Same value as ApiError.requestId and a successful response's request correlation id. Not a credential.",
        "schema": { "$ref": "#/components/schemas/Uuid" }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "401 SESSION_EXPIRED when the access or refresh token is missing or expired. 401 SESSION_REVOKED after logout. 401 REFRESH_REPLAY when a rotated refresh token is presented with a new Idempotency-Key. retryable is false.",
        "headers": { "X-Request-Id": { "$ref": "#/components/headers/RequestId" } },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
        }
      },
      "Forbidden": {
        "description": "403 PERSON_FORBIDDEN. The session tenant or person does not match the invitation or request. retryable is false. The client cannot choose another company or person.",
        "headers": { "X-Request-Id": { "$ref": "#/components/headers/RequestId" } },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
        }
      },
      "NotFound": {
        "description": "404 REQUEST_NOT_FOUND. No request with this id is visible in the session company. A known request for a different person is 403, not 404.",
        "headers": { "X-Request-Id": { "$ref": "#/components/headers/RequestId" } },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
        }
      },
      "InvitationGone": {
        "description": "410 INVITATION_EXPIRED when the token is missing, already used, revoked, or past its expiry. 410 REQUEST_CLOSED when the invitation is valid but the request is closed or past due. Do not retry the same token. Ask the advisor for a replacement link. retryable is false.",
        "headers": { "X-Request-Id": { "$ref": "#/components/headers/RequestId" } },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
        }
      },
      "RequestClosed": {
        "description": "410 REQUEST_CLOSED. The request is closed or past its due date. retryable is false.",
        "headers": { "X-Request-Id": { "$ref": "#/components/headers/RequestId" } },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
        }
      },
      "VersionOrHashConflict": {
        "description": "409 VERSION_CONFLICT or 409 HASH_CONFLICT. Also 409 IDEMPOTENCY_CONFLICT when the key was reused with a different body. retryable is false.",
        "headers": { "X-Request-Id": { "$ref": "#/components/headers/RequestId" } },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
        }
      },
      "IdempotencyConflict": {
        "description": "409 IDEMPOTENCY_CONFLICT. This Idempotency-Key was already used with different content. retryable is false.",
        "headers": { "X-Request-Id": { "$ref": "#/components/headers/RequestId" } },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
        }
      },
      "ValidationError": {
        "description": "422 VALIDATION_ERROR. The body failed shape checks. fieldErrors lists the fields when the server can name them. retryable is false until the body changes. Audio type and size use codes AUDIO_TYPE and AUDIO_SIZE.",
        "headers": { "X-Request-Id": { "$ref": "#/components/headers/RequestId" } },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
        }
      },
      "PayloadTooLarge": {
        "description": "413. The audio clip is over 25 MB. retryable is false until the client sends a smaller clip.",
        "headers": { "X-Request-Id": { "$ref": "#/components/headers/RequestId" } },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
        }
      },
      "RateLimited": {
        "description": "429 RATE_LIMITED, VOICE_LIMIT, or TRANSCRIPTION_LIMIT. retryable is true after the client waits.",
        "headers": { "X-Request-Id": { "$ref": "#/components/headers/RequestId" } },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
        }
      },
      "Unavailable": {
        "description": "503 TRANSCRIPTION_NOT_CONFIGURED or UNDERSTANDING_NOT_CONFIGURED. The advisor has not enabled the provider. Saved audio and typed words remain available. retryable is true after configuration, not by immediate repeat.",
        "headers": { "X-Request-Id": { "$ref": "#/components/headers/RequestId" } },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
        }
      }
    },
    "schemas": {
      "Uuid": {
        "type": "string",
        "format": "uuid"
      },
      "Sha256": {
        "type": "string",
        "pattern": "^[a-f0-9]{64}$",
        "minLength": 64,
        "maxLength": 64,
        "description": "Lowercase SHA-256 hex digest. Record hashes use the same canonical JSON hashing rule as existing DutyGraph record hashes."
      },
      "OpaqueToken": {
        "type": "string",
        "minLength": 32,
        "maxLength": 512,
        "pattern": "^[A-Za-z0-9_-]+$",
        "description": "Server-issued opaque token. Not a provider credential and not a browser session cookie."
      },
      "ApiError": {
        "type": "object",
        "additionalProperties": false,
        "required": ["code", "message", "retryable", "requestId"],
        "properties": {
          "code": {
            "type": "string",
            "pattern": "^[A-Z0-9_]+$",
            "minLength": 1,
            "maxLength": 64,
            "description": "Stable machine code. Examples: PERSON_FORBIDDEN, VERSION_CONFLICT, HASH_CONFLICT, IDEMPOTENCY_CONFLICT, INVITATION_EXPIRED, REQUEST_CLOSED, SESSION_EXPIRED, VALIDATION_ERROR."
          },
          "message": { "type": "string", "minLength": 1, "maxLength": 500 },
          "retryable": {
            "type": "boolean",
            "description": "True when a later attempt with the same body can succeed, such as a rate limit or a temporary provider outage. False for version, hash, permission, and validation failures."
          },
          "requestId": {
            "$ref": "#/components/schemas/Uuid",
            "description": "Matches the X-Request-Id response header."
          },
          "fieldErrors": {
            "type": "array",
            "maxItems": 50,
            "items": { "$ref": "#/components/schemas/FieldError" }
          }
        }
      },
      "FieldError": {
        "type": "object",
        "additionalProperties": false,
        "required": ["path", "message"],
        "properties": {
          "path": { "type": "string", "maxLength": 200 },
          "message": { "type": "string", "maxLength": 300 }
        }
      },
      "EnrollRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["invitationToken"],
        "properties": {
          "invitationToken": {
            "type": "string",
            "pattern": "^[a-f0-9]{64}$",
            "minLength": 64,
            "maxLength": 64,
            "description": "Single-use invitation token. 64 lowercase hex characters. Do not put this value in a URL or a log."
          },
          "deviceLabel": {
            "type": "string",
            "maxLength": 80,
            "description": "Optional name the participant can recognize, such as a phone model. Not an authorization claim."
          }
        }
      },
      "RefreshRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["refreshToken"],
        "properties": {
          "refreshToken": { "$ref": "#/components/schemas/OpaqueToken" }
        }
      },
      "ParticipantMobileSession": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "tokenType",
          "accessToken",
          "refreshToken",
          "accessTokenExpiresAt",
          "refreshTokenExpiresAt",
          "role",
          "deviceId",
          "idempotentReplay"
        ],
        "properties": {
          "tokenType": { "const": "Bearer" },
          "accessToken": { "$ref": "#/components/schemas/OpaqueToken" },
          "refreshToken": { "$ref": "#/components/schemas/OpaqueToken" },
          "accessTokenExpiresAt": { "type": "string", "format": "date-time" },
          "refreshTokenExpiresAt": { "type": "string", "format": "date-time" },
          "role": {
            "const": "participant_mobile",
            "description": "Server role for this session. Not the participant's job title."
          },
          "deviceId": {
            "$ref": "#/components/schemas/Uuid",
            "description": "Server-assigned device id. Display and support only. Authorization comes from the bearer token."
          },
          "idempotentReplay": {
            "type": "boolean",
            "description": "True when this body is the saved result of the same Idempotency-Key. Enroll always returns false."
          }
        }
      },
      "ParticipantProfile": {
        "type": "object",
        "additionalProperties": false,
        "required": ["personId", "companyId", "name", "email", "roleTitle", "team", "companyName"],
        "properties": {
          "personId": { "$ref": "#/components/schemas/Uuid" },
          "companyId": { "$ref": "#/components/schemas/Uuid" },
          "name": { "type": "string", "minLength": 1, "maxLength": 200 },
          "email": { "type": "string", "format": "email", "maxLength": 254 },
          "roleTitle": {
            "type": "string",
            "maxLength": 200,
            "description": "Job title from the person record. This is not the session role and not permission to act for the company."
          },
          "team": { "type": "string", "maxLength": 200 },
          "companyName": { "type": "string", "minLength": 1, "maxLength": 200 }
        }
      },
      "ParticipantRequestSummary": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "requestId",
          "version",
          "contentHash",
          "title",
          "type",
          "state",
          "dueDate",
          "questionCount",
          "needsYou"
        ],
        "properties": {
          "requestId": { "$ref": "#/components/schemas/Uuid" },
          "version": {
            "type": "integer",
            "minimum": 1,
            "description": "Current record version. Send this as expectedVersion on later commands."
          },
          "contentHash": {
            "$ref": "#/components/schemas/Sha256",
            "description": "Current request content hash. Send this as contentHash on later commands."
          },
          "title": { "type": "string", "minLength": 1, "maxLength": 200 },
          "type": {
            "type": "string",
            "enum": ["work", "leadership", "confirmation"],
            "description": "Same request types as the existing request record."
          },
          "state": {
            "type": "string",
            "enum": ["sent", "returned", "accepted", "withdrawn", "retracted", "superseded", "stale"]
          },
          "dueDate": { "type": "string", "format": "date" },
          "questionCount": { "type": "integer", "minimum": 0, "maximum": 10 },
          "needsYou": {
            "type": "boolean",
            "description": "True when the request is still open for this participant: state sent and not past due."
          },
          "noticePreview": { "type": "string", "maxLength": 500 }
        }
      },
      "ParticipantRequestList": {
        "type": "object",
        "additionalProperties": false,
        "required": ["requests"],
        "properties": {
          "requests": {
            "type": "array",
            "maxItems": 50,
            "items": { "$ref": "#/components/schemas/ParticipantRequestSummary" }
          }
        }
      },
      "ParticipantQuestion": {
        "type": "object",
        "additionalProperties": false,
        "required": ["questionId", "text"],
        "properties": {
          "questionId": { "type": "string", "minLength": 1, "maxLength": 100 },
          "text": { "type": "string", "minLength": 1, "maxLength": 200 }
        }
      },
      "ParticipantRequestDetail": {
        "type": "object",
        "additionalProperties": false,
        "description": "Question screen. Includes every list field plus the questions the participant answers.",
        "required": [
          "requestId",
          "version",
          "contentHash",
          "title",
          "type",
          "state",
          "dueDate",
          "questionCount",
          "needsYou",
          "questions",
          "questionPlanVersion",
          "gapFollowup"
        ],
        "properties": {
          "requestId": { "$ref": "#/components/schemas/Uuid" },
          "version": { "type": "integer", "minimum": 1 },
          "contentHash": { "$ref": "#/components/schemas/Sha256" },
          "title": { "type": "string", "minLength": 1, "maxLength": 200 },
          "type": { "type": "string", "enum": ["work", "leadership", "confirmation"] },
          "state": {
            "type": "string",
            "enum": ["sent", "returned", "accepted", "withdrawn", "retracted", "superseded", "stale"]
          },
          "dueDate": { "type": "string", "format": "date" },
          "questionCount": { "type": "integer", "minimum": 0, "maximum": 10 },
          "needsYou": { "type": "boolean" },
          "noticePreview": { "type": "string", "maxLength": 500 },
          "questions": {
            "type": "array",
            "minItems": 1,
            "maxItems": 10,
            "items": { "$ref": "#/components/schemas/ParticipantQuestion" }
          },
          "questionPlanVersion": { "type": "string", "maxLength": 100 },
          "gapFollowup": { "type": "boolean" },
          "stageLabel": { "type": "string", "maxLength": 200 },
          "notice": { "type": "string", "maxLength": 20000 }
        }
      },
      "TranscriptionMeta": {
        "type": "object",
        "additionalProperties": false,
        "required": ["expectedVersion", "contentHash", "consent"],
        "properties": {
          "expectedVersion": { "type": "integer", "minimum": 1 },
          "contentHash": { "$ref": "#/components/schemas/Sha256" },
          "consent": {
            "type": "boolean",
            "const": true,
            "description": "The participant agrees the server may send this clip to the configured transcription provider."
          },
          "language": {
            "type": "string",
            "minLength": 2,
            "maxLength": 16,
            "description": "Optional language hint, such as en. The server may ignore it."
          }
        }
      },
      "TranscriptionUpload": {
        "type": "object",
        "additionalProperties": false,
        "required": ["audio", "meta"],
        "properties": {
          "audio": {
            "type": "string",
            "format": "binary",
            "description": "One audio clip, at most 25 MB. Types: audio/webm, audio/mp4, audio/ogg, audio/wav, audio/mpeg."
          },
          "meta": { "$ref": "#/components/schemas/TranscriptionMeta" }
        }
      },
      "TranscriptionFromAsset": {
        "type": "object",
        "additionalProperties": false,
        "required": ["assetId", "expectedVersion", "contentHash", "consent"],
        "properties": {
          "assetId": {
            "$ref": "#/components/schemas/Uuid",
            "description": "Audio asset already stored for this request and this participant."
          },
          "expectedVersion": { "type": "integer", "minimum": 1 },
          "contentHash": { "$ref": "#/components/schemas/Sha256" },
          "consent": { "type": "boolean", "const": true },
          "language": { "type": "string", "minLength": 2, "maxLength": 16 },
          "retry": {
            "type": "boolean",
            "default": false,
            "description": "When false, a saved job for this asset is returned instead of starting another provider call."
          }
        }
      },
      "ProviderJobMetadata": {
        "type": "object",
        "additionalProperties": false,
        "description": "Audit fields for a provider job. No credential is included.",
        "required": [
          "jobId",
          "kind",
          "state",
          "provider",
          "model",
          "schemaVersion",
          "promptVersion",
          "requestHash",
          "store",
          "createdAt"
        ],
        "properties": {
          "jobId": { "$ref": "#/components/schemas/Uuid" },
          "kind": { "type": "string", "enum": ["audio_transcription", "proposed_understanding"] },
          "state": { "type": "string", "enum": ["running", "complete", "failed", "unknown"] },
          "provider": {
            "const": "xai",
            "description": "Provider name only."
          },
          "model": { "type": "string", "enum": ["grok-voice-transcribe-2.0", "grok-4.7"] },
          "schemaVersion": { "type": "string", "minLength": 1, "maxLength": 64 },
          "promptVersion": { "type": "string", "minLength": 1, "maxLength": 64 },
          "requestHash": { "$ref": "#/components/schemas/Sha256" },
          "responseHash": { "$ref": "#/components/schemas/Sha256" },
          "providerRequestId": {
            "type": "string",
            "maxLength": 128,
            "description": "Opaque id from the provider response, for audit. Not a credential."
          },
          "store": {
            "type": "boolean",
            "description": "Understanding jobs must report false. Transcription jobs report false unless the pinned provider has no retention flag."
          },
          "message": { "type": "string", "maxLength": 500 },
          "createdAt": { "type": "string", "format": "date-time" },
          "finishedAt": { "type": ["string", "null"], "format": "date-time" }
        }
      },
      "TranscriptionJob": {
        "allOf": [
          { "$ref": "#/components/schemas/ProviderJobMetadata" },
          {
            "type": "object",
            "properties": {
              "kind": { "const": "audio_transcription" },
              "model": { "const": "grok-voice-transcribe-2.0" },
              "schemaVersion": { "const": "xai-stt-companion-v1" }
            }
          }
        ]
      },
      "UnderstandingJob": {
        "allOf": [
          { "$ref": "#/components/schemas/ProviderJobMetadata" },
          {
            "type": "object",
            "properties": {
              "kind": { "const": "proposed_understanding" },
              "model": { "const": "grok-4.7" },
              "schemaVersion": { "const": "companion-understanding-v1" },
              "store": { "const": false }
            }
          }
        ]
      },
      "TranscriptionResult": {
        "type": "object",
        "additionalProperties": false,
        "required": ["reviewRequired", "idempotentReplay", "job"],
        "properties": {
          "text": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100000,
            "description": "Present when job.state is complete. The participant must read this text before submit."
          },
          "reviewRequired": { "const": true },
          "idempotentReplay": { "type": "boolean" },
          "assetId": { "$ref": "#/components/schemas/Uuid" },
          "audioChecksum": { "$ref": "#/components/schemas/Sha256" },
          "job": { "$ref": "#/components/schemas/TranscriptionJob" }
        }
      },
      "ProposedParticipantTask": {
        "type": "object",
        "additionalProperties": false,
        "description": "Proposal fields use the same names as the existing participant card review schema, except the participant decision. This is not a new Task Card schema and not a canonical record. Do not add owner or performer ids here.",
        "required": ["title", "duty", "inputs", "instructions", "output", "handoff", "software"],
        "properties": {
          "title": { "type": "string", "minLength": 1, "maxLength": 200 },
          "duty": { "type": "string", "minLength": 1, "maxLength": 200 },
          "purpose": { "type": "string", "maxLength": 3000 },
          "trigger": { "type": "string", "maxLength": 3000 },
          "humanGate": { "type": "string", "maxLength": 3000 },
          "inputs": { "type": "string", "maxLength": 3000 },
          "instructions": { "type": "string", "maxLength": 3000 },
          "output": { "type": "string", "maxLength": 3000 },
          "handoff": { "type": "string", "maxLength": 3000 },
          "software": { "type": "string", "maxLength": 20000 }
        }
      },
      "ConfirmedParticipantTask": {
        "type": "object",
        "additionalProperties": false,
        "description": "The proposed task plus the participant's decision. decision uses the existing participant card values: correct, not_mine, unsure.",
        "required": ["title", "duty", "inputs", "instructions", "output", "handoff", "software", "decision"],
        "properties": {
          "title": { "type": "string", "minLength": 1, "maxLength": 200 },
          "duty": { "type": "string", "minLength": 1, "maxLength": 200 },
          "purpose": { "type": "string", "maxLength": 3000 },
          "trigger": { "type": "string", "maxLength": 3000 },
          "humanGate": { "type": "string", "maxLength": 3000 },
          "inputs": { "type": "string", "maxLength": 3000 },
          "instructions": { "type": "string", "maxLength": 3000 },
          "output": { "type": "string", "maxLength": 3000 },
          "handoff": { "type": "string", "maxLength": 3000 },
          "software": { "type": "string", "maxLength": 20000 },
          "decision": { "type": "string", "enum": ["correct", "not_mine", "unsure"] }
        }
      },
      "UnderstandingRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": ["expectedVersion", "contentHash", "source", "sourceText"],
        "properties": {
          "expectedVersion": { "type": "integer", "minimum": 1 },
          "contentHash": { "$ref": "#/components/schemas/Sha256" },
          "source": { "type": "string", "enum": ["typed", "reviewed_transcript"] },
          "sourceText": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100000,
            "description": "The participant's words after they typed them or read the transcript."
          },
          "transcriptJobId": {
            "$ref": "#/components/schemas/Uuid",
            "description": "Required when source is reviewed_transcript."
          }
        }
      },
      "ProposedUnderstanding": {
        "type": "object",
        "additionalProperties": false,
        "required": ["confirmationState", "summary", "tasks", "gaps", "contentHash", "idempotentReplay", "job"],
        "properties": {
          "confirmationState": {
            "const": "proposed",
            "description": "Always proposed. This operation never returns accepted, canonical, or company-truth."
          },
          "summary": { "type": "string", "maxLength": 3000 },
          "tasks": {
            "type": "array",
            "maxItems": 30,
            "items": { "$ref": "#/components/schemas/ProposedParticipantTask" }
          },
          "gaps": {
            "type": "array",
            "maxItems": 12,
            "description": "Missing steps and unanswered questions. The model must not fill these with invented work.",
            "items": { "type": "string", "minLength": 1, "maxLength": 200 }
          },
          "contentHash": {
            "$ref": "#/components/schemas/Sha256",
            "description": "Hash of this proposal. Submit must send this value as understanding.contentHash and attestation.understandingHash."
          },
          "idempotentReplay": { "type": "boolean" },
          "job": { "$ref": "#/components/schemas/UnderstandingJob" }
        }
      },
      "SubmitUnderstanding": {
        "type": "object",
        "additionalProperties": false,
        "required": ["contentHash", "tasks"],
        "properties": {
          "contentHash": {
            "$ref": "#/components/schemas/Sha256",
            "description": "contentHash from the proposed understanding the participant reviewed."
          },
          "tasks": {
            "type": "array",
            "maxItems": 30,
            "items": { "$ref": "#/components/schemas/ConfirmedParticipantTask" }
          }
        }
      },
      "SubmitAttestation": {
        "type": "object",
        "additionalProperties": false,
        "required": ["requestContentHash", "understandingHash"],
        "properties": {
          "requestContentHash": {
            "$ref": "#/components/schemas/Sha256",
            "description": "Must equal the top-level contentHash."
          },
          "understandingHash": {
            "$ref": "#/components/schemas/Sha256",
            "description": "Must equal understanding.contentHash."
          },
          "transcriptHash": {
            "$ref": "#/components/schemas/Sha256",
            "description": "Optional SHA-256 of the reviewed transcript text."
          }
        }
      },
      "SubmitRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "expectedVersion",
          "contentHash",
          "participantConfirmed",
          "text",
          "source",
          "understanding",
          "attestation"
        ],
        "properties": {
          "expectedVersion": { "type": "integer", "minimum": 1 },
          "contentHash": {
            "$ref": "#/components/schemas/Sha256",
            "description": "contentHash from the request detail the participant reviewed."
          },
          "participantConfirmed": {
            "type": "boolean",
            "const": true,
            "description": "The participant attests they reviewed the understanding. Confirmation records their understanding. It does not accept Task Cards as company truth."
          },
          "text": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100000,
            "description": "The participant's submitted words. These words stay saved if a later provider job fails."
          },
          "source": { "type": "string", "enum": ["typed", "reviewed_transcript"] },
          "transcriptJobId": { "$ref": "#/components/schemas/Uuid" },
          "audioAssetId": { "$ref": "#/components/schemas/Uuid" },
          "understanding": { "$ref": "#/components/schemas/SubmitUnderstanding" },
          "attestation": { "$ref": "#/components/schemas/SubmitAttestation" }
        }
      },
      "SubmitReconciliation": {
        "type": "object",
        "additionalProperties": false,
        "required": ["state"],
        "properties": {
          "jobId": { "$ref": "#/components/schemas/Uuid" },
          "state": {
            "type": "string",
            "enum": ["queued", "not_requested"],
            "description": "queued means a follow-up job was saved after the response commit. It is not advisor approval."
          }
        }
      },
      "SubmitResult": {
        "type": "object",
        "additionalProperties": false,
        "required": ["responseId", "requestId", "requestState", "version", "idempotentReplay", "reconciliation"],
        "properties": {
          "responseId": { "$ref": "#/components/schemas/Uuid" },
          "requestId": { "$ref": "#/components/schemas/Uuid" },
          "requestState": { "const": "returned" },
          "version": { "type": "integer", "minimum": 1 },
          "idempotentReplay": {
            "type": "boolean",
            "description": "True when this Idempotency-Key already had a saved result."
          },
          "reconciliation": { "$ref": "#/components/schemas/SubmitReconciliation" }
        }
      }
    }
  }
}
