{
  "openapi": "3.0.3",
  "info": {
    "title": "Elenscio Partner API",
    "version": "1.0.0-preview",
    "description": "HTTP API for submitting scientific manuscripts to Elenscio's AI peer-review pipeline and retrieving the resulting reviews.\n\n> **Not yet provisioned.** This document describes the contract partners will call. The base URL below is a placeholder — the callable endpoint is not live yet, and credentials are not being issued. Published so integrators can evaluate the contract before onboarding.\n\n## Authentication\n\nEvery operation requires a **Cloudflare Access Service Token**, sent as two headers on every request:\n\n```\nCF-Access-Client-Id: <your-client-id>.access\nCF-Access-Client-Secret: <your-client-secret>\n```\n\nTokens are issued per partner. Requests without both headers are rejected at the edge before reaching the API, with a `302` to a login page or a `403` — not a JSON error body.\n\n## The review flow\n\nReviewing a manuscript takes four calls, because uploads go **directly to Google Cloud Storage** and the pipeline runs asynchronously.\n\n1. `POST /v1/uploads/sign` — declare your filenames, receive a `batch_id` and one signed `PUT` URL per file.\n2. `PUT` each file's bytes to its `signed_put_url` (a plain HTTP PUT to Google Cloud Storage). Send **no** Elenscio auth headers — the signature is the authorization — but you **must** send a `Content-Type` matching the file, because it is part of what the URL was signed with. URLs expire; see the operation for the TTL.\n3. `POST /v1/jobs` — submit the batch by **reference** (the `gcs_path` values from step 1, never file bytes). Returns `202` immediately; the pipeline runs in a background worker.\n4. Poll `GET /v1/jobs/{batch_id}` until the status is terminal, then `GET /v1/runs/{batch_id}` for per-article detail and `GET /v1/runs/{batch_id}/artifacts/{filename}` to download each review.\n\nA *batch* is one submission; a *run* is one article within it. Submitting a single manuscript is a batch of one.\n\n## Batch status lifecycle\n\n`GET /v1/jobs/{batch_id}` returns one of:\n\n| Status | Terminal | Meaning |\n|---|---|---|\n| `pending` | no | Accepted, worker not yet started. |\n| `processing` | no | At least one article is being reviewed. |\n| `completed` | yes | Every article finished successfully. |\n| `completed_with_errors` | yes | **Some articles failed.** Inspect per-run `status` in `GET /v1/runs/{batch_id}`; do not treat this as total failure or as total success. |\n| `failed` | yes | The batch as a whole did not complete. |\n| `stopped` | yes | Cancelled before finishing. |\n\nPer-article (`run`) status is narrower: `pending`, `processing`, `completed`, `failed`.\n\nPoll no more often than every 5 seconds. Reviews typically take minutes per article.\n\n## Errors\n\nApplication errors use FastAPI's shape, `{\"detail\": \"...\"}`, with a conventional status code. Authentication failures happen at the Cloudflare edge and do **not** use this shape.\n\nA batch that exists but belongs to another partner is reported as `404`, not `403`, so batch identifiers cannot be probed for existence.\n\n`error` fields on a batch or run are **operational text for humans, not a stable enumeration**. Log and display them; do not parse them or branch on their content — the wording changes without notice.\n\n## Configuration\n\nReviews run with Elenscio's default model and agent configuration. Model selection and per-agent feature flags are not partner-settable.\n\n## More\n\nThe [integration guide](/integration-guide.md) covers the same flow in prose, plus retry/idempotency behaviour and limits.",
    "contact": {
      "name": "Elenscio",
      "url": "https://www.elenscio.com"
    }
  },
  "servers": [
    {
      "url": "https://api.elenscio.com",
      "description": "Placeholder — not yet provisioned. Do not integrate against this host yet."
    }
  ],
  "security": [
    {
      "cfAccessClientId": [],
      "cfAccessClientSecret": []
    }
  ],
  "tags": [
    {
      "name": "uploads",
      "description": "Signed URLs for direct-to-GCS manuscript upload."
    },
    {
      "name": "jobs",
      "description": "Submitting a batch and polling its progress."
    },
    {
      "name": "runs",
      "description": "Per-article results and artifact download."
    },
    {
      "name": "health",
      "description": "Liveness."
    }
  ],
  "paths": {
    "/v1/health": {
      "get": {
        "tags": ["health"],
        "summary": "Liveness probe",
        "description": "Returns 200 when the API is serving. Requires a Service Token like every other operation — there is no unauthenticated endpoint on this host.",
        "operationId": "getHealth",
        "responses": {
          "200": {
            "description": "Service is up.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Health" }
              }
            }
          }
        }
      }
    },
    "/v1/uploads/sign": {
      "post": {
        "tags": ["uploads"],
        "summary": "Create a batch and sign upload URLs",
        "description": "Step 1 of the flow. Mints a `batch_id` and returns one signed `PUT` URL per filename.\n\nSigned URLs expire **900 seconds (15 minutes) after issue** by default; the exact TTL is deployment-configurable, so treat it as \"short\" and upload promptly rather than caching URLs. Re-call this operation if they expire — it mints a new `batch_id`.\n\nOnly `.pdf` (manuscripts, human reviews) and `.json` (structured human reviews) are accepted, at most 50 files per call.\n\n### Uploading\n\nSend the raw bytes with a plain `PUT` to `signed_put_url`, and **no** Elenscio authentication headers.\n\nYou must, however, send a `Content-Type` header, and it must match exactly — the signed URL commits to a content type, and Google Cloud Storage verifies it as part of the signature. A mismatched or missing header fails with `SignatureDoesNotMatch`:\n\n| File | Required `Content-Type` |\n|---|---|\n| `*.pdf` | `application/pdf` |\n| `*.json` | `application/json` |\n\n```bash\ncurl -X PUT --upload-file manuscript.pdf \\\n  -H 'Content-Type: application/pdf' \\\n  \"$SIGNED_PUT_URL\"\n```\n\nThis leg is server-to-server. The bucket's CORS policy allows only Elenscio's own web app origins, so a `PUT` issued from a browser on your domain will fail preflight — upload from your backend.",
        "operationId": "signUploads",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/SignRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Batch created and URLs signed.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SignResponse" }
              }
            }
          },
          "400": {
            "description": "Too many files, or an unsupported file extension.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          }
        }
      }
    },
    "/v1/jobs": {
      "post": {
        "tags": ["jobs"],
        "summary": "Submit an uploaded batch for review",
        "description": "Step 3 of the flow. Submits a batch whose files are **already uploaded** to the signed URLs from step 1.\n\nEach `gcs_path` must be exactly the value returned by `POST /v1/uploads/sign` for that filename — the server verifies the layout and rejects mismatches with a `400` rather than dispatching a job that would fail mid-pipeline.\n\nReturns `202` as soon as the batch is accepted. The review itself runs asynchronously; poll `GET /v1/jobs/{batch_id}`.",
        "operationId": "createJob",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/JobCreateRequest" }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Batch accepted; review dispatched.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/JobAccepted" }
              }
            }
          },
          "400": {
            "description": "A `gcs_path` does not match the signed upload layout, or the batch is otherwise invalid.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          }
        }
      }
    },
    "/v1/jobs/{batch_id}": {
      "get": {
        "tags": ["jobs"],
        "summary": "Poll batch progress",
        "description": "Step 4 of the flow. Cheap progress poll; use `GET /v1/runs/{batch_id}` once the status is terminal to get per-article detail and artifact filenames.",
        "operationId": "getJobStatus",
        "parameters": [{ "$ref": "#/components/parameters/BatchId" }],
        "responses": {
          "200": {
            "description": "Current batch status.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/JobStatus" }
              }
            }
          },
          "404": {
            "description": "No such batch, or it does not belong to you.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          }
        }
      }
    },
    "/v1/runs/{batch_id}": {
      "get": {
        "tags": ["runs"],
        "summary": "Get batch detail with per-article runs",
        "description": "Per-article outcome for a batch, including the `artifacts` list naming every downloadable file. Read this after the batch reaches a terminal status — in particular after `completed_with_errors`, to find which articles failed and why.",
        "operationId": "getBatchDetail",
        "parameters": [{ "$ref": "#/components/parameters/BatchId" }],
        "responses": {
          "200": {
            "description": "Batch detail.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/BatchDetail" }
              }
            }
          },
          "404": {
            "description": "No such batch, or it does not belong to you.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          }
        }
      }
    },
    "/v1/runs/{batch_id}/artifacts/{filename}": {
      "get": {
        "tags": ["runs"],
        "summary": "Download a review artifact",
        "description": "Downloads one artifact by name. Take `filename` from the `artifacts` array of a run in `GET /v1/runs/{batch_id}` — do not construct it yourself.\n\nThe response is a file, not JSON: Markdown reviews are `text/markdown`, structured reviews `application/json`, both with a `Content-Disposition: attachment` filename.",
        "operationId": "downloadArtifact",
        "parameters": [
          { "$ref": "#/components/parameters/BatchId" },
          {
            "name": "filename",
            "in": "path",
            "required": true,
            "description": "Artifact filename, taken verbatim from a run's `artifacts` array.",
            "schema": { "type": "string" },
            "example": "manuscript_review.md"
          }
        ],
        "responses": {
          "200": {
            "description": "The artifact bytes.",
            "headers": {
              "Content-Disposition": {
                "description": "`attachment; filename=\"...\"`",
                "schema": { "type": "string" }
              }
            },
            "content": {
              "text/markdown": {
                "schema": { "type": "string" }
              },
              "application/json": {
                "schema": { "type": "object" }
              },
              "application/octet-stream": {
                "schema": { "type": "string", "format": "binary" }
              }
            }
          },
          "404": {
            "description": "No such artifact or batch, or the batch does not belong to you.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "cfAccessClientId": {
        "type": "apiKey",
        "in": "header",
        "name": "CF-Access-Client-Id",
        "description": "Cloudflare Access Service Token client ID. Required together with `CF-Access-Client-Secret`."
      },
      "cfAccessClientSecret": {
        "type": "apiKey",
        "in": "header",
        "name": "CF-Access-Client-Secret",
        "description": "Cloudflare Access Service Token secret. Required together with `CF-Access-Client-Id`."
      }
    },
    "parameters": {
      "BatchId": {
        "name": "batch_id",
        "in": "path",
        "required": true,
        "description": "Batch identifier returned by `POST /v1/uploads/sign`.",
        "schema": { "type": "string" },
        "example": "batch_20260812_143201_a1b2c3"
      }
    },
    "schemas": {
      "Health": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "description": "`ok` when serving.",
            "example": "ok"
          }
        }
      },
      "Error": {
        "type": "object",
        "description": "Application error body. Edge authentication failures do not use this shape.",
        "properties": {
          "detail": {
            "type": "string",
            "description": "Human-readable explanation.",
            "example": "gcs_path for 'paper.pdf' must be 'gs://.../inputs/<batch_id>/paper.pdf'"
          }
        }
      },
      "SignRequest": {
        "type": "object",
        "required": ["filenames"],
        "properties": {
          "filenames": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "description": "Files you are about to upload. `.pdf` and `.json` only.",
            "items": { "type": "string" },
            "example": ["paper.pdf", "paper_human_review.pdf"]
          }
        }
      },
      "SignResponse": {
        "type": "object",
        "required": ["batch_id", "uploads"],
        "properties": {
          "batch_id": {
            "type": "string",
            "description": "Identifier for the batch you are creating. Pass it to `POST /v1/jobs`."
          },
          "uploads": {
            "type": "array",
            "description": "One entry per requested filename.",
            "items": { "$ref": "#/components/schemas/SignedUpload" }
          }
        }
      },
      "SignedUpload": {
        "type": "object",
        "required": ["filename", "gcs_path", "signed_put_url"],
        "properties": {
          "filename": {
            "type": "string",
            "description": "The filename you requested."
          },
          "gcs_path": {
            "type": "string",
            "description": "Where the file will live once uploaded. Pass this back verbatim in `POST /v1/jobs`.",
            "example": "gs://elenscio-batches/inputs/batch_20260812_143201_a1b2c3/manuscript.pdf"
          },
          "signed_put_url": {
            "type": "string",
            "format": "uri",
            "description": "Short-lived URL to `PUT` the raw bytes to. Send no Elenscio auth headers on this request."
          }
        }
      },
      "FileRef": {
        "type": "object",
        "required": ["filename", "gcs_path"],
        "properties": {
          "filename": { "type": "string" },
          "gcs_path": {
            "type": "string",
            "description": "Must equal the `gcs_path` returned for this filename by `POST /v1/uploads/sign`."
          }
        }
      },
      "JobCreateRequest": {
        "type": "object",
        "required": ["batch_id", "articles"],
        "properties": {
          "batch_id": {
            "type": "string",
            "description": "From `POST /v1/uploads/sign`."
          },
          "articles": {
            "type": "array",
            "minItems": 1,
            "description": "Manuscript PDFs to review, by reference.",
            "items": { "$ref": "#/components/schemas/FileRef" }
          },
          "human_reviews": {
            "type": "array",
            "description": "Optional existing human reviews. Supplying them opts this batch into alignment assessment — you do not (and cannot) set feature flags yourself.\n\n**Reviews are paired to articles by filename**, so naming is load-bearing. For an article `paper.pdf`:\n\n| Review filename | Pairs? |\n|---|---|\n| `paper.json` | yes — structured review, takes priority |\n| `paper_human_review.pdf` | yes — note the exact `_human_review` suffix |\n| `review.pdf`, `paper_review.pdf` | **no** — silently unpaired, no alignment for that article |\n\nAn unpaired review is not an error; the article is simply reviewed without alignment.",
            "items": { "$ref": "#/components/schemas/FileRef" },
            "default": []
          },
          "alignment_guidelines": {
            "type": "string",
            "description": "Optional review guidelines (e.g. a conference's criteria) to assess against.",
            "default": ""
          },
          "batch_name": {
            "type": "string",
            "description": "Optional human-readable label for the batch.",
            "default": ""
          }
        }
      },
      "JobAccepted": {
        "type": "object",
        "required": ["batch_id", "status"],
        "properties": {
          "batch_id": { "type": "string" },
          "status": {
            "type": "string",
            "description": "Always `pending` on acceptance.",
            "example": "pending"
          }
        }
      },
      "BatchStatus": {
        "type": "string",
        "description": "Batch lifecycle state. `completed_with_errors` means some articles failed — inspect per-run status.",
        "enum": [
          "pending",
          "processing",
          "completed",
          "completed_with_errors",
          "failed",
          "stopped"
        ]
      },
      "RunStatus": {
        "type": "string",
        "description": "Per-article state.",
        "enum": ["pending", "processing", "completed", "failed"]
      },
      "JobStatus": {
        "type": "object",
        "required": ["batch_id", "status"],
        "properties": {
          "batch_id": { "type": "string" },
          "status": { "$ref": "#/components/schemas/BatchStatus" },
          "stage": {
            "type": "string",
            "description": "Coarse label for what the pipeline is doing now. Informational; do not branch on it.",
            "example": "Rigor analysis"
          },
          "progress": {
            "type": "number",
            "format": "float",
            "description": "Completion fraction, 0.0–1.0. Always 1.0 once terminal.",
            "minimum": 0,
            "maximum": 1
          },
          "total_runs": {
            "type": "integer",
            "description": "Articles in the batch."
          },
          "completed_runs": { "type": "integer" },
          "failed_runs": { "type": "integer" },
          "error": {
            "type": "string",
            "nullable": true,
            "description": "Why the batch ended abnormally; null otherwise. Operational text for humans, not a stable enumeration — log it, do not parse it."
          }
        }
      },
      "BatchDetail": {
        "type": "object",
        "required": ["batch_id", "status", "runs"],
        "properties": {
          "batch_id": { "type": "string" },
          "name": {
            "type": "string",
            "description": "Label supplied as `batch_name`, or a generated one."
          },
          "status": { "$ref": "#/components/schemas/BatchStatus" },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "ISO-8601 UTC."
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true,
            "description": "ISO-8601 UTC; null until terminal."
          },
          "progress": {
            "type": "number",
            "format": "float",
            "minimum": 0,
            "maximum": 1
          },
          "total_runs": { "type": "integer" },
          "completed_runs": { "type": "integer" },
          "failed_runs": { "type": "integer" },
          "error": {
            "type": "string",
            "nullable": true,
            "description": "Batch-level failure reason; null otherwise. Operational text for humans, not a stable enumeration — log it, do not parse it."
          },
          "runs": {
            "type": "array",
            "description": "One entry per article, oldest first.",
            "items": { "$ref": "#/components/schemas/Run" }
          }
        }
      },
      "Run": {
        "type": "object",
        "required": ["run_id", "article_filename", "status", "artifacts"],
        "properties": {
          "run_id": { "type": "string" },
          "article_filename": {
            "type": "string",
            "description": "The manuscript this run reviewed."
          },
          "status": { "$ref": "#/components/schemas/RunStatus" },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "ISO-8601 UTC."
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "error": {
            "type": "string",
            "nullable": true,
            "description": "Why this article failed; null otherwise. Operational text for humans, not a stable enumeration — log it, do not parse it."
          },
          "artifacts": {
            "type": "array",
            "description": "Downloadable outputs for this run. Empty until the run completes.",
            "items": { "$ref": "#/components/schemas/Artifact" }
          }
        }
      },
      "Artifact": {
        "type": "object",
        "required": ["filename", "kind", "format"],
        "properties": {
          "filename": {
            "type": "string",
            "description": "Pass verbatim to `GET /v1/runs/{batch_id}/artifacts/{filename}`.",
            "example": "manuscript_review.md"
          },
          "kind": {
            "type": "string",
            "description": "`review` is the AI peer review; `alignment` is the comparison against supplied human reviews, present only when `human_reviews` were submitted.",
            "enum": ["review", "alignment"]
          },
          "format": {
            "type": "string",
            "description": "`md` is the human-readable report; `json` the structured payload.",
            "enum": ["md", "json"]
          }
        }
      }
    }
  }
}
