openapi: 3.0.3
info:
  title: Ldata.fr API
  description: |
    French open **case-law** + **legislation** API (Licence Ouverte 2.0).

    - Jurisprudence: Judilibre / DILA (~2M decisions, FTS)
    - Législation: LEGI DILA (codes + TNC consolidés; `/codes` = codes, `/statutes` = lois/décrets/ordonnances/arrêtés)
    - EU: EUR-Lex legislation (consolidated FR) + CJEU + ECHR (HUDOC) case-law
    - Administrative: DILA JADE case-law (CE + CAA + TA)
    - Collective agreements: DILA KALI (IDCC conventions collectives)
    - Tax doctrine: BOFiP-Impôts (DGFiP open data, versioned/opposable)
    - Constitutional: DILA CONSTIT (Conseil constitutionnel — DC, QPC, LP, AN, SEN, …)
    - Authorities: ADLC (Autorité de la concurrence, data.gouv.fr) + CNIL (DILA CNIL fund)
    - Circulars: DILA CIRCULAIRES (circulaires et instructions des administrations, full text where a PDF was extracted, else metadata)
    - Official journal: DILA JORF (as published)
    - Legislative dossiers: DILA DOLE (travaux préparatoires, metadata)
    - Company agreements: DILA ACCO (accords d'entreprise, metadata)
    - Commercial notices: DILA BODACC (RCS / PCL / accounts publications)
    - Public procurement: DILA BOAMP (marchés publics)

    Authenticate with `Authorization: Bearer <api_key>` or `X-Api-Key: <api_key>`.
    Create keys from the dashboard after signup.

    **Find here, cite on the official source** — every hit includes `citation` and `verify_*`.

    Also available: human docs `/docs`, LLM docs `/llms.txt`, tool schemas `/api-tools.json`.
  version: 1.1.0
  license:
    name: Licence Ouverte 2.0 (data)
    url: https://www.etalab.gouv.fr/licence-ouverte-open-licence/

servers:
  - url: /api
    description: Relative to this host

tags:
  - name: health
  - name: search
  - name: decisions
  - name: legislation
  - name: eu
  - name: admin-caselaw
  - name: collective-agreements
  - name: tax-doctrine
  - name: constitutional
  - name: authorities
  - name: circulars
  - name: official-journal
  - name: legislative-dossiers
  - name: company-agreements
  - name: commercial-notices
  - name: public-procurement
  - name: account

paths:
  /health:
    get:
      tags: [health]
      summary: Call once to see if corpora are up and how stale they are
      security: []
      responses:
        "200":
          description: Corpus available (may be stale)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
        "503":
          description: Corpus missing or degraded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"

  /find:
    get:
      tags: [search]
      summary: One full-text query over every corpus (federated search — start here when unsure where to look)
      description: |
        Fans out to each corpus search and returns one hit list grouped by corpus
        (best match first within a corpus; scores are not comparable across corpora).
        Costs **one** quota unit whatever the number of corpora. Ids are
        `<corpus>:<id>` — open them with `/fetch/{id}`. A corpus that fails is listed in
        `meta.unavailable` instead of failing the whole search.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
          description: FTS5 query (phrases in double quotes)
        - name: corpora
          in: query
          schema: { type: string }
          description: |
            Comma list or `all`. Default: caselaw, codes, statutes, admin_caselaw, constitutional,
            eu, collective_agreements, tax_doctrine, authorities, circulars, official_journal,
            legislative_dossiers. Opt-in: company_agreements, commercial_notices, public_procurement.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 10, default: 3 }
          description: Hits per corpus
      responses:
        "200":
          description: Federated hits (`results[]` with id, corpus, title, date, url, snippet)
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /fetch/{id}:
    get:
      tags: [search]
      summary: Open any /find hit by its `<corpus>:<id>` (text + metadata + official url)
      description: |
        Normalized document: `id`, `corpus`, `title`, `text`, `url` (official verify link) and
        the corpus-specific fields under `metadata`. LEGI ids are
        `codes:<slug>/<number>` or `statutes:<slug>/<number>`. One quota unit.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: e.g. `caselaw:607975589ba5988459c49ed9`, `codes:code-civil/373-2-2`
        - name: as_of
          in: query
          schema: { type: string, format: date }
          description: codes/statutes only — article text in force that day (404 if none, no fallback)
        - name: full
          in: query
          schema: { type: boolean }
          description: Untruncated text
      responses:
        "200":
          description: Normalized document
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown id in that corpus
        "429":
          $ref: "#/components/responses/RateLimited"

  /sql/schema:
    get:
      tags: [search]
      summary: Corpus databases available to /sql, or the tables, columns and FTS tables of one
      parameters:
        - name: database
          in: query
          schema:
            type: string
            enum: [justice, legis, eu, admin, kali, bofip, constit, authorities, circ, jorf, dole, acco, bodacc, boamp]
          description: Omit to list databases
      responses:
        "200":
          description: "`databases[]` or `tables[]` (name, type table|view|virtual, columns, indexes)"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: Database not built on this server

  /sql:
    post:
      tags: [search]
      summary: One read-only SQLite SELECT on one corpus database (GET with the same params also works)
      description: |
        For counts, aggregations, joins and lists the search endpoints cannot express.
        Runs in a sandbox: read-only connection, authorizer allowlist (no ATTACH, writes,
        or pragmas other than introspection), one statement, stopped after 60 s, paged
        (`max_rows` ≤ 1000 + `offset`, follow `next_offset`). Never reaches the users
        database. Full-text: `SELECT d.* FROM documents_fts JOIN documents d ON
        d.rowid = documents_fts.rowid WHERE documents_fts MATCH 'terme' ORDER BY rank`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [database, sql]
              properties:
                database:
                  type: string
                  enum: [justice, legis, eu, admin, kali, bofip, constit, authorities, circ, jorf, dole, acco, bodacc, boamp]
                sql: { type: string, description: "One SELECT (WITH / EXPLAIN ok); ? placeholders" }
                bind: { type: array, items: { type: string }, description: Values for ? placeholders }
                max_rows: { type: integer, minimum: 1, maximum: 1000, description: "Rows per page, default 100" }
                offset: { type: integer, minimum: 0, description: "Pass next_offset from the previous page" }
                max_cell_chars: { type: integer, minimum: 1, maximum: 50000, description: "Default 2000" }
      responses:
        "200":
          description: "columns, rows (arrays), row_count, offset, has_more, next_offset, truncated_cells, took_ms"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: "sql_limit_exceeded — over the plan time budget or result too large (charged)"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Database not built on this server

  /search:
    get:
      tags: [search]
      summary: Find judicial decisions (Cassation, cours d'appel, TJ, commerce) — not statute text, not CE/CAA/TA
      description: >
        Judicial case-law only (cc/ca/tj/tcom). For the statute text use `/codes/...`
        or `/statutes/...`. For CE/CAA/TA use `/admin-caselaw/search`.
      parameters:
        - name: q
          in: query
          schema: { type: string }
          description: FTS5 query (`"phrase"`, AND/OR/NOT, NEAR/20, prefix*)
        - name: mode
          in: query
          schema: { type: string, enum: [fts, semantic, hybrid], default: fts }
          description: >
            fts = FTS5 keyword search. semantic = vector search (bge-m3 cosine)
            over embedded decisions; plain-language q, fact patterns work.
            hybrid = RRF fusion of both rankings (recommended for natural
            language; ranking depth grows with the page up to 200 — deeper
            offset+limit is 400). semantic/hybrid allow only jurisdiction/
            after/before/limit/offset alongside q; hits gain `similarity`
            when the vector leg saw them; meta gains `mode` (always set),
            `hybrid_depth` (hybrid only), `semantic_corpus` (filtered
            embedded coverage — a subset of the corpus) and `semantic_index`.
            Falls back to FTS with `meta.mode=fts` + `meta.semantic_fallback`
            (stable code) when the embedding backend is unavailable.
        - name: semantic
          in: query
          schema: { type: boolean }
          description: Legacy alias of mode=semantic.
        - name: jurisdiction
          in: query
          schema: { type: string }
          description: "cc | ca | tj | tcom, or comma list e.g. cc,ca"
        - name: location
          in: query
          schema: { type: string }
        - name: number
          in: query
          schema: { type: string }
        - name: article
          in: query
          schema: { type: string }
          description: e.g. 1240, L132-1, 373-2-2
        - name: outcome
          in: query
          schema:
            type: string
            enum: [cassation, rejet, irrecevabilite, infirmation, confirmation, autre]
        - name: after
          in: query
          schema: { type: string, format: date }
        - name: before
          in: query
          schema: { type: string, format: date }
        - name: sort
          in: query
          schema: { type: string, enum: [authority, rank, date], default: authority }
        - name: syn
          in: query
          schema: { type: boolean }
        - name: reversed
          in: query
          schema: { type: boolean }
        - name: cites
          in: query
          schema: { type: string }
          description: Decision id — returns Cassation decisions it cites
        - name: cited_by
          in: query
          schema: { type: string }
          description: Cassation decision id — returns decisions citing it
        - name: facets
          in: query
          schema: { type: string }
          description: >
            FTS only. `true` / `1` for both solution+chamber, or comma list
            `solution,chamber`. Counts are over the top-2000 FTS rank pool
            (not the full MATCH). meta.facets.solution buckets via
            norm_solution; meta.facets.chamber top 30 raw chamber labels
            (empty string → `(none)`).
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, maximum: 1000, default: 0 }
          description: >
            Pagination offset (max 1000). sort=authority|date re-rank a
            candidate pool — offset+limit must stay ≤ 2000. hybrid max
            offset+limit is 200 (see meta.hybrid_depth).
      responses:
        "200":
          description: Search results
          headers:
            X-RateLimit-Limit:
              schema: { type: integer }
            X-RateLimit-Remaining:
              schema: { type: integer }
            X-RateLimit-Reset:
              schema: { type: integer }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /decisions/{id}:
    get:
      tags: [decisions]
      summary: Open one judicial decision by id from /search (prefer full=false)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: full
          in: query
          schema: { type: boolean }
          description: Return full text (default truncated)
      responses:
        "200":
          description: Decision payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Decision"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Not found
        "429":
          $ref: "#/components/responses/RateLimited"

  /caselaw/by-article/{code}:
    get:
      tags: [search]
      summary: Find decisions that cite this article number (visa) — not the statute text
      description: |
        Returns **judicial decisions** that cite this article as a visa.
        This is **not** the statute text — use `/codes/{slug}/articles/{num}` for LEGI.
        Legacy alias: `/articles/{code}` (same payload, less clear).
      parameters:
        - name: code
          in: path
          required: true
          schema: { type: string }
          description: e.g. 373-2-2 or L132-1
        - name: jurisdiction
          in: query
          schema: { type: string, enum: [cc, ca, tj, tcom] }
        - name: after
          in: query
          schema: { type: string, format: date }
        - name: before
          in: query
          schema: { type: string, format: date }
        - name: sort
          in: query
          schema: { type: string, enum: [authority, rank, date], default: authority }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
      responses:
        "200":
          description: Case-law hits citing this visa
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CaselawByArticleResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /articles/{code}:
    get:
      tags: [search]
      summary: "[Legacy] Same as /caselaw/by-article/{code}"
      description: Deprecated path name — prefer `/caselaw/by-article/{code}`. Not statute text.
      parameters:
        - name: code
          in: path
          required: true
          schema: { type: string }
        - name: jurisdiction
          in: query
          schema: { type: string, enum: [cc, ca, tj, tcom] }
        - name: after
          in: query
          schema: { type: string, format: date }
        - name: before
          in: query
          schema: { type: string, format: date }
        - name: sort
          in: query
          schema: { type: string, enum: [authority, rank, date], default: authority }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
      responses:
        "200":
          description: Same as caselaw/by-article
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CaselawByArticleResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /codes:
    get:
      tags: [legislation]
      summary: Discover a code's instrument_slug (Code civil, …). Default = codes only
      security: []
      parameters:
        - name: kind
          in: query
          schema: { type: string, enum: [code, text] }
          description: Default is codes only
        - name: nature
          in: query
          schema: { type: string }
          description: e.g. CODE, LOI, DECRET, ORDONNANCE
        - name: q
          in: query
          schema: { type: string }
          description: Filter title / slug / NOR
        - name: limit
          in: query
          schema: { type: integer, maximum: 5000 }
      responses:
        "200":
          description: Instrument catalogue
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CodesList"
        "503":
          description: Legis corpus missing

  /codes/search:
    get:
      tags: [legislation]
      summary: Full-text search article bodies inside codes — not case-law, not the JO, not consolidated lois/décrets
      description: >
        Code articles only (C. civ., C. trav., …). For consolidated lois/décrets use
        `/statutes/search`. For the JO as published use `/official-journal/search`.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
        - name: code
          in: query
          schema: { type: string }
          description: Restrict to instrument slug
        - name: kind
          in: query
          schema: { type: string, enum: [code, text] }
        - name: nature
          in: query
          schema: { type: string }
        - name: as_of
          in: query
          schema: { type: string, format: date }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
      responses:
        "200":
          description: Matching articles (snippets)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LegisSearchResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /codes/{id}:
    get:
      tags: [legislation]
      summary: Metadata for one LEGI instrument by slug — not the article text
      security: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: Slug, e.g. code-civil
      responses:
        "200":
          description: Instrument metadata + links
        "404":
          description: Unknown slug
        "503":
          description: Legis corpus missing

  /codes/{code_id}/articles:
    get:
      tags: [legislation]
      summary: Table of contents of one code or consolidated text (structure only, no bodies)
      parameters:
        - name: code_id
          in: path
          required: true
          schema: { type: string }
        - name: q
          in: query
          schema: { type: string }
          description: Filter num / breadcrumb
        - name: as_of
          in: query
          schema: { type: string, format: date }
          description: Structure as of this date (default today)
        - name: limit
          in: query
          schema: { type: integer, default: 500, maximum: 5000 }
      responses:
        "200":
          description: TOC rows (no full text)
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown slug

  /codes/{code_id}/articles/{num}:
    get:
      tags: [legislation]
      summary: Read one article (code or consolidated law) as in force on as_of — not case-law
      description: Selects the version in force on `as_of` (default today). Full history via `/versions`.
      parameters:
        - name: code_id
          in: path
          required: true
          schema: { type: string }
          description: e.g. code-civil
        - name: num
          in: path
          required: true
          schema: { type: string }
          description: e.g. 373-2-2 or L132-1
        - name: as_of
          in: query
          schema: { type: string, format: date }
      responses:
        "200":
          description: Article payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LegisArticleResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Article not found
        "429":
          $ref: "#/components/responses/RateLimited"

  /codes/{code_id}/articles/{num}/versions:
    get:
      tags: [legislation]
      summary: Version timeline for one code article number (after fetching the article)
      parameters:
        - name: code_id
          in: path
          required: true
          schema: { type: string }
        - name: num
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Ordered list of versions (date_start, date_end, status, id)
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No versions

  /statutes:
    get:
      tags: [legislation]
      summary: Discover a consolidated law/decree/arrêté (not a code) — slug, NOR or cid
      description: Never returns CODE instruments. Codes stay on `/codes`.
      security: []
      parameters:
        - name: nature
          in: query
          schema: { type: string }
          description: LOI, DECRET, ORDONNANCE, ARRETE, …
        - name: q
          in: query
          schema: { type: string }
          description: Filter title / slug / NOR
        - name: nor
          in: query
          schema: { type: string }
          description: Exact NOR (case-insensitive)
        - name: limit
          in: query
          schema: { type: integer, maximum: 5000 }
      responses:
        "200":
          description: TNC instrument catalogue
        "503":
          description: Legis corpus missing

  /statutes/search:
    get:
      tags: [legislation]
      summary: Full-text search consolidated laws and decrees — not codes, not the JO as published
      description: >
        TNC (lois, décrets, ordonnances, arrêtés consolidés). Never returns CODE
        articles — those are `/codes/search`. Raw JO publication is `/official-journal/search`.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
        - name: nature
          in: query
          schema: { type: string }
        - name: instrument_slug
          in: query
          schema: { type: string }
        - name: nor
          in: query
          schema: { type: string }
        - name: as_of
          in: query
          schema: { type: string, format: date }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
      responses:
        "200":
          description: Matching TNC articles (snippets)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LegisSearchResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /statutes/{id}:
    get:
      tags: [legislation]
      summary: One consolidated non-code text by slug, cid or NOR (codes 404 — use /codes/{slug})
      security: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: instrument_slug, JORFTEXT/LEGITEXT cid, or NOR
      responses:
        "200":
          description: Instrument metadata + links
        "404":
          description: Unknown, or this id is a code (use /codes)
        "503":
          description: Legis corpus missing

  /statutes/{id}/articles:
    get:
      tags: [legislation]
      summary: Table of contents of one consolidated law/decree (structure only)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: q
          in: query
          schema: { type: string }
        - name: as_of
          in: query
          schema: { type: string, format: date }
        - name: limit
          in: query
          schema: { type: integer, default: 500, maximum: 5000 }
      responses:
        "200":
          description: TOC rows (no full text)
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown or a code

  /statutes/{id}/articles/{num}:
    get:
      tags: [legislation]
      summary: Read one consolidated-law article as in force on as_of
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: num
          in: path
          required: true
          schema: { type: string }
        - name: as_of
          in: query
          schema: { type: string, format: date }
      responses:
        "200":
          description: Article payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LegisArticleResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Article not found
        "429":
          $ref: "#/components/responses/RateLimited"

  /statutes/{id}/articles/{num}/versions:
    get:
      tags: [legislation]
      summary: Version timeline for one consolidated-law article number
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: num
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Ordered list of versions
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No versions

  /eu/search:
    get:
      tags: [eu]
      summary: Search EU legislation (EUR-Lex FR) plus CJEU and ECHR — not French domestic courts
      description: |
        One FTS over EU legislation (EUR-Lex, consolidated versions, French),
        CJEU case-law and ECHR (HUDOC) judgments. Every hit carries `url` —
        re-verify on EUR-Lex / HUDOC before citing.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
          description: FTS5 query over titles + full text
        - name: source
          in: query
          schema: { type: string, enum: [eurlex_law, cjeu, echr] }
        - name: after
          in: query
          schema: { type: string, format: date }
          description: Document date, inclusive
        - name: before
          in: query
          schema: { type: string, format: date }
          description: Document date, exclusive
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: EU search results
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EuSearchResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: EU corpus missing

  /eu/documents/{id}:
    get:
      tags: [eu]
      summary: Open one EU document by id, CELEX or ECLI from /eu/search (prefer full=false)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: Document id, CELEX (e.g. 32009R0004) or ECLI
        - name: full
          in: query
          schema: { type: boolean }
          description: Return full text (default truncated at 50000 chars)
      responses:
        "200":
          description: Document payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EuDocument"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown id / CELEX / ECLI
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: EU corpus missing

  /admin-caselaw/search:
    get:
      tags: [admin-caselaw]
      summary: Find administrative-court decisions (CE, CAA, TA) — not judicial caselaw (/search)
      description: |
        FTS over the DILA JADE fund — Conseil d'État, cours administratives
        d'appel and tribunaux administratifs. Every hit carries `url` —
        re-verify on Légifrance before citing.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
          description: FTS5 query over titles + full text
        - name: source
          in: query
          schema: { type: string, enum: [ce, caa, ta, autre] }
        - name: after
          in: query
          schema: { type: string, format: date }
          description: Decision date, inclusive
        - name: before
          in: query
          schema: { type: string, format: date }
          description: Decision date, exclusive
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Administrative case-law search results
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminSearchResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Admin corpus missing

  /admin-caselaw/decisions/{id}:
    get:
      tags: [admin-caselaw]
      summary: Open one administrative decision by CETATEXT id (prefer full=false)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: CETATEXT id (e.g. CETATEXT000047520000)
        - name: full
          in: query
          schema: { type: boolean }
          description: Return full text (default truncated at 50000 chars)
      responses:
        "200":
          description: Decision payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminDecision"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown CETATEXT id
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Admin corpus missing

  /collective-agreements/search:
    get:
      tags: [collective-agreements]
      summary: Search industry collective agreements (IDCC / KALI) — not company-level ACCO
      description: |
        FTS over the DILA KALI fund — conventions collectives, their attached
        textes (avenants/accords) and articles. Every hit carries `url` —
        re-verify on Légifrance before citing.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
          description: FTS5 query over titles + full text
        - name: idcc
          in: query
          schema: { type: string }
          description: IDCC number of the collective agreement, e.g. 1979
        - name: doc_type
          in: query
          schema: { type: string, enum: [convention, texte, article] }
        - name: after
          in: query
          schema: { type: string, format: date }
          description: date_start, inclusive
        - name: before
          in: query
          schema: { type: string, format: date }
          description: date_start, exclusive
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Collective agreement search results
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CollectiveAgreementSearchResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Kali corpus missing

  /collective-agreements/documents/{id}:
    get:
      tags: [collective-agreements]
      summary: Open one KALI document by id (prefer full=false)
      description: |
        Only `article` documents carry body text; `convention` and `texte`
        return their indexed titles.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: KALI id (e.g. KALIARTI000000000202)
        - name: full
          in: query
          schema: { type: boolean }
          description: Return full text (default truncated at 50000 chars)
      responses:
        "200":
          description: Document payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CollectiveAgreementDocument"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown KALI id
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Kali corpus missing

  /tax-doctrine/search:
    get:
      tags: [tax-doctrine]
      summary: Search BOFiP tax doctrine; pass as_of for the version opposable that day
      description: |
        FTS over BOFiP-Impôts (DGFiP open data) — versioned, opposable
        administrative doctrine. `as_of` restricts to the version opposable
        that day. Every hit carries `url` — re-verify on bofip.impots.gouv.fr
        before citing.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
          description: FTS5 query over titles + full text
        - name: doc_type
          in: query
          schema: { type: string }
          description: Normalized content type facet (source-driven, no fixed enum), e.g. commentaire, bareme
        - name: serie
          in: query
          schema: { type: string }
          description: BOFiP series code, e.g. IF, TVA, IR, RFPI
        - name: as_of
          in: query
          schema: { type: string, format: date }
          description: Only the version opposable that day
        - name: after
          in: query
          schema: { type: string, format: date }
          description: Document date on or after (YYYY-MM-DD, inclusive)
        - name: before
          in: query
          schema: { type: string, format: date }
          description: Document date strictly before (YYYY-MM-DD, exclusive)
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Tax doctrine search results
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaxDoctrineSearchResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: BOFiP corpus missing

  /tax-doctrine/documents/{id}:
    get:
      tags: [tax-doctrine]
      summary: Open one BOFiP document (version id, BOI-… or node); optional as_of
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: Version id (BOI-…-YYYYMMDD), juridical identifiant (BOI-…) or node id (…-PGP)
        - name: as_of
          in: query
          schema: { type: string, format: date }
          description: Version opposable that day (default latest version)
        - name: full
          in: query
          schema: { type: boolean }
          description: Return full text (default truncated at 50000 chars)
      responses:
        "200":
          description: Document payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaxDoctrineDocument"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown id, or no version opposable as_of
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: BOFiP corpus missing

  /constitutional/search:
    get:
      tags: [constitutional]
      summary: Search Conseil constitutionnel decisions (DC, QPC, electoral) — not Cassation or CE
      description: |
        FTS over the DILA CONSTIT fund — Conseil constitutionnel decisions
        since 1958 (contrôle de constitutionnalité, QPC, lois de pays,
        contentieux électoral, and other contentieux). Every hit carries
        `url` — re-verify on conseil-constitutionnel.fr before citing.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
          description: FTS5 query over titles + full text
        - name: decision_type
          in: query
          schema: { type: string, enum: [dc, qpc, lp, an, sen, autre] }
        - name: after
          in: query
          schema: { type: string, format: date }
          description: Decision date, inclusive
        - name: before
          in: query
          schema: { type: string, format: date }
          description: Decision date, exclusive
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Constitutional decisions search results
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConstitutionalSearchResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Constit corpus missing

  /constitutional/decisions/{id}:
    get:
      tags: [constitutional]
      summary: Open one Conseil constitutionnel decision by CONSTEXT id (prefer full=false)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: CONSTEXT id (e.g. CONSTEXT000051585985)
        - name: full
          in: query
          schema: { type: boolean }
          description: Return full text (default truncated at 50000 chars)
      responses:
        "200":
          description: Decision payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConstitutionalDecision"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown CONSTEXT id
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Constit corpus missing

  /authorities/search:
    get:
      tags: [authorities]
      summary: Search ADLC (competition), CNIL (data protection) or AMF OAM filings (not AMF sanctions)
      description: |
        FTS over ADLC (Autorité de la concurrence — antitrust/merger control,
        sourced from its official data.gouv.fr dataset) and CNIL (data
        protection, DILA CNIL fund). Every hit carries `url` — re-verify on
        the authority's official site before citing.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
          description: FTS5 query over titles + full text
        - name: authority
          in: query
          schema: { type: string, enum: [adlc, cnil] }
        - name: doc_type
          in: query
          schema: { type: string }
          description: Source-driven, no fixed enum (ADLC d|a|mc|dcc|dex|soa; CNIL sanction|mise_en_demeure|...)
        - name: after
          in: query
          schema: { type: string, format: date }
          description: Decision date, inclusive
        - name: before
          in: query
          schema: { type: string, format: date }
          description: Decision date, exclusive
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Authority decisions search results
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthoritiesSearchResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Authorities corpus missing

  /authorities/documents/{id}:
    get:
      tags: [authorities]
      summary: Open one ADLC or CNIL document by id (AMF hits already carry a PDF url)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: CNILTEXT id (CNIL) or decision number e.g. 26-DCC-149 (ADLC)
        - name: full
          in: query
          schema: { type: boolean }
          description: Return full text (default truncated at 50000 chars)
      responses:
        "200":
          description: Decision payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthoritiesDocument"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown document id
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Authorities corpus missing

  /circulars/search:
    get:
      tags: [circulars]
      summary: Search government circulaires; prefer the official PDF url (full_text=true means body extracted)
      description: |
        FTS over the DILA CIRCULAIRES fund. Circulaires are published as
        PDF; the XML carries only metadata. The PDF body is extracted with
        pdftotext at index time — `snippet` is the full body when `full_text`
        is true, title + résumé + mots-clés + destinataire otherwise (the
        2009-2014 legacy bulk export has no PDF at all; some later drops may
        be missing one or fail to extract). Every hit carries `url` (the
        official PDF) — re-verify before citing regardless of `full_text`.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
          description: FTS5 query over titles + résumé/mots-clés
        - name: doc_type
          in: query
          schema:
            type: string
            enum: [organisation_services, directives_ministre, interpretation_juridique, autre]
        - name: after
          in: query
          schema: { type: string, format: date }
          description: Date de signature, inclusive
        - name: before
          in: query
          schema: { type: string, format: date }
          description: Date de signature, exclusive
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Circulars search results
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CircularsSearchResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Circ corpus missing

  /circulars/documents/{id}:
    get:
      tags: [circulars]
      summary: Open one circulaire by ID_CIRCULAIRE (prefer full=false)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: ID_CIRCULAIRE (e.g. 45652)
        - name: full
          in: query
          schema: { type: boolean }
          description: Lift the 50000-char cap (only matters when `full_text` is true)
      responses:
        "200":
          description: Circular payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CircularsDocument"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown ID_CIRCULAIRE
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Circ corpus missing

  /official-journal/search:
    get:
      tags: [official-journal]
      summary: Search the Journal officiel as published — not the consolidated text (/statutes/search)
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
        - name: nature
          in: query
          schema: { type: string }
          description: LOI, DECRET, ARRETE, ORDONNANCE, DECISION, AVIS, …
        - name: ministry
          in: query
          schema: { type: string }
          description: Substring, case-insensitive (e.g. agriculture) — trigram-indexed
        - name: doc_type
          in: query
          schema: { type: string, enum: [loi, decret, arrete, ordonnance, decision, avis, autre] }
        - name: after
          in: query
          schema: { type: string, format: date }
        - name: before
          in: query
          schema: { type: string, format: date }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0 }
      responses:
        "200":
          description: JORF hits with Légifrance url
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Jorf corpus missing

  /official-journal/documents/{id}:
    get:
      tags: [official-journal]
      summary: Open one JO text by JORFTEXT id (prefer full=false)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: full
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: JORF document payload
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown JORFTEXT
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Jorf corpus missing

  /legislative-dossiers/search:
    get:
      tags: [legislative-dossiers]
      summary: Find travaux préparatoires (dossiers) — not the statute text
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
        - name: dossier_type
          in: query
          schema: { type: string, enum: [projet_loi, proposition_loi, autre] }
        - name: after
          in: query
          schema: { type: string, format: date }
        - name: before
          in: query
          schema: { type: string, format: date }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0 }
      responses:
        "200":
          description: DOLE hits with Légifrance url
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Dole corpus missing

  /legislative-dossiers/documents/{id}:
    get:
      tags: [legislative-dossiers]
      summary: Open one legislative dossier by JORFDOLE id
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: full
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: DOLE document payload
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown JORFDOLE
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Dole corpus missing

  /company-agreements/search:
    get:
      tags: [company-agreements]
      summary: Search company-level accords d'entreprise (ACCO) — not industry IDCC (KALI)
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
        - name: idcc
          in: query
          schema: { type: string }
        - name: after
          in: query
          schema: { type: string, format: date }
        - name: before
          in: query
          schema: { type: string, format: date }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0 }
      responses:
        "200":
          description: ACCO hits with Légifrance url
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Acco corpus missing

  /company-agreements/documents/{id}:
    get:
      tags: [company-agreements]
      summary: Open one ACCOTEXT id (prefer full=false)
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: full
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: ACCO document payload
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown ACCOTEXT
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Acco corpus missing

  /commercial-notices/search:
    get:
      tags: [commercial-notices]
      summary: Search BODACC publication notices (RCS, insolvency, accounts) — not a live SIRENE register
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
        - name: edition
          in: query
          schema: { type: string, enum: [a, b, c] }
        - name: doc_type
          in: query
          schema: { type: string }
        - name: siren
          in: query
          schema: { type: string }
        - name: after
          in: query
          schema: { type: string, format: date }
        - name: before
          in: query
          schema: { type: string, format: date }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0 }
      responses:
        "200":
          description: BODACC hits with bodacc.fr url
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Bodacc corpus missing

  /commercial-notices/notices/{id}:
    get:
      tags: [commercial-notices]
      summary: Open one BODACC notice by nojo or public_id
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: full
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: BODACC notice payload
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown notice
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Bodacc corpus missing

  /public-procurement/search:
    get:
      tags: [public-procurement]
      summary: Search BOAMP public-procurement notices
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
        - name: doc_type
          in: query
          schema: { type: string }
        - name: after
          in: query
          schema: { type: string, format: date }
        - name: before
          in: query
          schema: { type: string, format: date }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0 }
      responses:
        "200":
          description: BOAMP hits with boamp.fr url
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Boamp corpus missing

  /public-procurement/notices/{id}:
    get:
      tags: [public-procurement]
      summary: Open one BOAMP notice by IDWEB
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: full
          in: query
          schema: { type: boolean }
      responses:
        "200":
          description: BOAMP notice payload
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown IDWEB
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Boamp corpus missing

  /me:
    get:
      tags: [account]
      summary: Remaining daily quota for this API key (call near limits)
      responses:
        "200":
          description: Account info
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Me"
        "401":
          $ref: "#/components/responses/Unauthorized"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API key from the dashboard (`oj_…`)
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key

  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    BadRequest:
      description: Invalid query
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: Daily or per-minute quota exceeded
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Health:
      type: object
      properties:
        status: { type: string, enum: [ok, stale, degraded] }
        corpus: { type: boolean }
        decisions: { type: integer }
        by_jurisdiction:
          type: object
          additionalProperties: { type: integer }
        max_decision_date: { type: string }
        file_mtime: { type: string, format: date-time }
        age_days: { type: integer }
        stale: { type: boolean }
        stale_after_days: { type: integer }
        last_update:
          type: object
          properties:
            id: { type: integer }
            finished_at: { type: string, format: date-time }
            decisions_count: { type: integer }
            trigger: { type: string }
        legis:
          type: object
          properties:
            available: { type: boolean }
            instruments: { type: integer }
            codes: { type: integer }
            articles: { type: integer }
            by_nature:
              type: object
              additionalProperties: { type: integer }
            age_days: { type: integer }
            stale: { type: boolean }
            file_mtime: { type: string, format: date-time }

    SearchHit:
      type: object
      properties:
        id: { type: string }
        citation: { type: string }
        jurisdiction: { type: string }
        location: { type: string }
        decision_date: { type: string, description: "Day of the ruling" }
        number: { type: string }
        outcome: { type: string }
        publication: { type: string, nullable: true }
        snippet: { type: string }
        verify_url: { type: string }
        verify_note: { type: string }
        possibly_reversed: { type: boolean, description: "High-confidence reverse only (unique city+date peer or RG match)" }
        reversal_signal:
          type: object
          nullable: true
          properties:
            status: { type: string, enum: [none, possible, likely] }
            confidence: { type: string, enum: [none, low, high] }
            method: { type: string }
            peer_count: { type: integer }
            matched_cassations: { type: integer }
            note: { type: string }
        similarity:
          type: number
          nullable: true
          description: >
            Cosine vs the query (semantic/hybrid modes, when the vector leg saw
            this hit). A per-query ranking signal — not calibrated, not
            comparable across queries or model versions.

    SearchResponse:
      type: object
      properties:
        query: { type: object }
        results:
          type: array
          items:
            $ref: "#/components/schemas/SearchHit"
        meta:
          type: object
          properties:
            count: { type: integer }
            limit: { type: integer }
            took_ms: { type: integer }
            mode:
              type: string
              enum: [fts, semantic, hybrid]
              description: "Mode that actually ran; always present (fts is the default path)"
            hybrid_depth:
              type: integer
              description: "Hybrid only — candidates taken from each leg before RRF (≤ 200)"
            semantic_fallback:
              type: string
              description: >
                Stable code explaining a fallback (no_embeddings_store,
                embedding_backend_error, vector_extension_unavailable,
                vector_index_unusable, model_mismatch, dimension_mismatch, …)
            semantic_index:
              type: string
              enum: [vec0, scan]
              description: "KNN backend used by the vector leg"
            semantic_corpus:
              type: integer
              description: "Embedded decisions matching the filters (subset of the full corpus)"
            facets:
              type: object
              description: >
                Present when facets requested (FTS only). Counts over the
                top-2000 rank pool, not the full MATCH set.
              properties:
                pool: { type: integer }
                solution:
                  type: object
                  additionalProperties: { type: integer }
                  description: "norm_solution buckets → count"
                chamber:
                  type: object
                  additionalProperties: { type: integer }
                  description: "Raw chamber label (or (none)) → count, top 30"
            quota: { $ref: "#/components/schemas/Quota" }

    CaselawByArticleResponse:
      type: object
      properties:
        corpus: { type: string, enum: [caselaw] }
        visa: { type: string, description: "Normalized article number cited in case-law" }
        results:
          type: array
          items:
            $ref: "#/components/schemas/SearchHit"
        meta:
          type: object
          properties:
            corpus: { type: string }
            meaning: { type: string }
            description: { type: string }
            statute_text_hint: { type: string }
            canonical_path: { type: string }

    Decision:
      type: object
      properties:
        id: { type: string }
        source: { type: string }
        jurisdiction: { type: string }
        location: { type: string }
        decision_date: { type: string, description: "Day of the ruling (not Judilibre update day)" }
        number: { type: string }
        outcome: { type: string, description: "Raw solution text from source" }
        publication: { type: string }
        chamber: { type: string }
        type: { type: string }
        title: { type: string }
        source_updated_on: { type: string, description: "Judilibre snapshot freshness (when present)" }
        citation: { type: string }
        verify_url: { type: string }
        verify_note: { type: string }
        text: { type: string }
        truncated: { type: boolean }
        text_length: { type: integer }
        possibly_reversed: { type: boolean, description: "High-confidence reverse only" }
        reversal_signal:
          type: object
          nullable: true
          properties:
            status: { type: string }
            confidence: { type: string }
            method: { type: string }
            peer_count: { type: integer }
            matched_cassations: { type: integer }
            note: { type: string }
        cites_pourvois:
          type: array
          description: "Pourvois cited in the decision text (0..N Cassation decisions per number)"
          items:
            type: object
            properties:
              numero_norm: { type: string }
              resolved: { type: boolean }
              decision_count: { type: integer }
              decisions:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string }
                    number: { type: string }
                    decision_date: { type: string }
                    outcome: { type: string }
                    role: { type: string, enum: [principal, related] }
        cites_edges:
          type: array
          description: "Materialized citing→cited decision ids (after justice:graph_build)"
          items:
            type: object
            properties:
              cited_id: { type: string }
              numero_norm: { type: string }
              role: { type: string, enum: [principal, related] }
              verify_url: { type: string }
        cited_by:
          type: array
          description: "Decisions that cite this one (materialized edges)"
          items:
            type: object
            properties:
              citing_id: { type: string }
              numero_norm: { type: string }
              role: { type: string }
              verify_url: { type: string }
        graph:
          type: object
          description: "Reverse links (high-confidence only) — find-aid, re-verify on Judilibre"
          properties:
            note: { type: string }
            reversed_by:
              type: array
              items:
                type: object
            reverses_ca:
              type: object
              nullable: true
        quota: { $ref: "#/components/schemas/Quota" }

    Me:
      type: object
      properties:
        email: { type: string }
        plan: { type: string }
        plan_name: { type: string }
        api_key:
          type: object
          properties:
            name: { type: string }
            prefix: { type: string }
            last_used_at: { type: string, format: date-time, nullable: true }
        quota: { $ref: "#/components/schemas/Quota" }

    Quota:
      type: object
      properties:
        plan: { type: string }
        day_limit: { type: integer }
        day_used: { type: integer }
        day_remaining: { type: integer }
        remaining_day: { type: integer }
        limit_day: { type: integer }
        rpm_limit: { type: integer }
        max_limit: { type: integer }

    CodesList:
      type: object
      properties:
        instruments:
          type: array
          items:
            $ref: "#/components/schemas/Instrument"
        codes:
          type: array
          items:
            $ref: "#/components/schemas/Instrument"
        meta: { type: object }

    Instrument:
      type: object
      properties:
        id: { type: string }
        slug: { type: string }
        title: { type: string }
        short_title: { type: string }
        kind: { type: string, enum: [code, text] }
        nature: { type: string }
        nor: { type: string }
        text_number: { type: string }
        article_count: { type: integer }

    LegisSearchResponse:
      type: object
      properties:
        query: { type: string }
        results:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              citation: { type: string }
              instrument_slug: { type: string, description: "URL key for the instrument (e.g. code-civil)" }
              instrument_title: { type: string }
              number: { type: string }
              snippet: { type: string }
              legi_status: { type: string, description: "Raw LEGI ETAT (VIGUEUR, ABROGE, MODIFIE, …)" }
              in_force_on_as_of: { type: boolean, description: "True if date_start..date_end covers as_of" }
              href: { type: string }
        meta: { type: object }

    LegisArticleResponse:
      type: object
      properties:
        article:
          type: object
          properties:
            id: { type: string }
            citation: { type: string }
            text: { type: string }
            structure:
              type: object
              properties:
                instrument_slug: { type: string }
                instrument_id: { type: string }
                instrument_title: { type: string }
                number: { type: string }
                number_norm: { type: string }
                path: { type: array, items: { type: string } }
            vigueur:
              type: object
              description: |
                legi_status = source ETAT label.
                in_force_on_as_of = date window covers as_of (authoritative for history).
              properties:
                legi_status: { type: string }
                in_force_on_as_of: { type: boolean }
                date_start: { type: string, format: date }
                date_end: { type: string, format: date }
                as_of: { type: string, format: date }
            verify_url: { type: string }
        meta: { type: object }

    EuSearchResponse:
      type: object
      properties:
        query: { type: string }
        results:
          type: array
          items:
            type: object
            properties:
              id: { type: string, description: "CELEX number or HUDOC itemid" }
              source: { type: string, enum: [eurlex_law, cjeu, echr] }
              doc_type: { type: string }
              title: { type: string }
              date: { type: string, format: date, description: "Document date" }
              celex: { type: string, nullable: true }
              ecli: { type: string, nullable: true }
              url: { type: string, description: "EUR-Lex / HUDOC verify link" }
              snippet: { type: string }
        meta:
          type: object
          properties:
            source: { type: string, enum: [eur_lex_hudoc] }
            count: { type: integer }
            limit: { type: integer }
            offset: { type: integer }
            has_more: { type: boolean }
            note: { type: string }
            quota: { $ref: "#/components/schemas/Quota" }

    EuDocument:
      type: object
      properties:
        id: { type: string }
        source: { type: string, enum: [eurlex_law, cjeu, echr] }
        doc_type: { type: string }
        title: { type: string }
        date: { type: string, format: date }
        celex: { type: string, nullable: true }
        ecli: { type: string, nullable: true }
        lang: { type: string }
        in_force: { type: boolean, nullable: true }
        date_start: { type: string, format: date, nullable: true }
        date_end: { type: string, format: date, nullable: true }
        consolidates:
          type: string
          nullable: true
          description: CELEX of the base act (consolidated versions)
        url: { type: string, description: "EUR-Lex / HUDOC verify link" }
        verify_note: { type: string }
        text: { type: string }
        truncated: { type: boolean }
        text_length: { type: integer }
        quota: { $ref: "#/components/schemas/Quota" }

    AdminSearchResponse:
      type: object
      properties:
        query: { type: string }
        results:
          type: array
          items:
            type: object
            properties:
              id: { type: string, description: "CETATEXT id" }
              source: { type: string, enum: [ce, caa, ta, autre] }
              doc_type: { type: string }
              title: { type: string }
              date: { type: string, format: date, description: "Decision date" }
              number: { type: string, description: "Case number" }
              url: { type: string, description: "Légifrance verify link" }
              snippet: { type: string }
        meta:
          type: object
          properties:
            source: { type: string, enum: [dila_jade] }
            count: { type: integer }
            limit: { type: integer }
            offset: { type: integer }
            has_more: { type: boolean }
            note: { type: string }
            quota: { $ref: "#/components/schemas/Quota" }

    AdminDecision:
      type: object
      properties:
        id: { type: string }
        source: { type: string, enum: [ce, caa, ta, autre] }
        doc_type: { type: string }
        title: { type: string }
        date: { type: string, format: date }
        number: { type: string }
        url: { type: string, description: "Légifrance verify link" }
        verify_note: { type: string }
        text: { type: string }
        truncated: { type: boolean }
        text_length: { type: integer }
        quota: { $ref: "#/components/schemas/Quota" }

    CollectiveAgreementSearchResponse:
      type: object
      properties:
        query: { type: string }
        results:
          type: array
          items:
            type: object
            properties:
              id: { type: string, description: "KALICONT / KALITEXT / KALIARTI id" }
              idcc: { type: string, description: "IDCC number of the collective agreement" }
              doc_type: { type: string, enum: [convention, texte, article] }
              nature: { type: string, description: "Convention collective, Avenant, Accord, …" }
              number: { type: string }
              title: { type: string }
              etat: { type: string, description: "VIGUEUR_ETEN, ABROGE, …" }
              date_start: { type: string, format: date }
              date_end: { type: string, format: date }
              url: { type: string, description: "Légifrance verify link" }
              snippet: { type: string }
        meta:
          type: object
          properties:
            source: { type: string, enum: [dila_kali] }
            count: { type: integer }
            limit: { type: integer }
            offset: { type: integer }
            has_more: { type: boolean }
            idcc_filter: { type: string }
            doc_type_filter: { type: string }
            note: { type: string }
            quota: { $ref: "#/components/schemas/Quota" }

    CollectiveAgreementDocument:
      type: object
      properties:
        id: { type: string }
        idcc: { type: string }
        doc_type: { type: string, enum: [convention, texte, article] }
        nature: { type: string }
        number: { type: string }
        title: { type: string }
        etat: { type: string }
        date_start: { type: string, format: date }
        date_end: { type: string, format: date }
        url: { type: string, description: "Légifrance verify link" }
        verify_note: { type: string }
        text:
          type: string
          description: "Only `article` documents carry body text; convention/texte return their indexed titles"
        truncated: { type: boolean }
        text_length: { type: integer }
        quota: { $ref: "#/components/schemas/Quota" }

    TaxDoctrineSearchResponse:
      type: object
      properties:
        query: { type: string }
        results:
          type: array
          items:
            type: object
            properties:
              id: { type: string, description: "Version id, e.g. BOI-IF-AUT-10-20120912" }
              doc_id: { type: string, description: "BOFiP node id, e.g. 1900-PGP" }
              doc_type: { type: string, description: "Normalized content type facet (no fixed enum)" }
              serie: { type: string }
              division: { type: string }
              identifiant: { type: string, description: "Juridical id, e.g. BOI-IF-AUT-10" }
              title: { type: string }
              date: { type: string, format: date, description: "Version date" }
              date_start: { type: string, format: date }
              date_end: { type: string, format: date, nullable: true, description: "Null while still in force" }
              url: { type: string, description: "bofip.impots.gouv.fr verify link" }
              snippet: { type: string }
        meta:
          type: object
          properties:
            source: { type: string, enum: [bofip_dgfip] }
            count: { type: integer }
            limit: { type: integer }
            offset: { type: integer }
            has_more: { type: boolean }
            doc_type_filter: { type: string }
            serie_filter: { type: string }
            as_of: { type: string, format: date }
            note: { type: string }
            quota: { $ref: "#/components/schemas/Quota" }

    TaxDoctrineDocument:
      type: object
      properties:
        id: { type: string }
        doc_id: { type: string }
        doc_type: { type: string }
        serie: { type: string }
        division: { type: string }
        identifiant: { type: string }
        title: { type: string }
        date: { type: string, format: date }
        date_start: { type: string, format: date }
        date_end: { type: string, format: date, nullable: true }
        url: { type: string, description: "bofip.impots.gouv.fr verify link" }
        verify_note: { type: string }
        text: { type: string }
        truncated: { type: boolean }
        text_length: { type: integer }
        quota: { $ref: "#/components/schemas/Quota" }

    ConstitutionalSearchResponse:
      type: object
      properties:
        query: { type: string }
        results:
          type: array
          items:
            type: object
            properties:
              id: { type: string, description: "CONSTEXT id" }
              decision_type: { type: string, enum: [dc, qpc, lp, an, sen, autre] }
              title: { type: string }
              date: { type: string, format: date, description: "Decision date" }
              number: { type: string, description: "e.g. 2025-881" }
              solution: { type: string, description: "e.g. Conformité, Rejet, Inéligibilité" }
              url: { type: string, description: "conseil-constitutionnel.fr verify link" }
              snippet: { type: string }
        meta:
          type: object
          properties:
            source: { type: string, enum: [dila_constit] }
            count: { type: integer }
            limit: { type: integer }
            offset: { type: integer }
            has_more: { type: boolean }
            note: { type: string }
            quota: { $ref: "#/components/schemas/Quota" }

    ConstitutionalDecision:
      type: object
      properties:
        id: { type: string }
        decision_type: { type: string, enum: [dc, qpc, lp, an, sen, autre] }
        title: { type: string }
        date: { type: string, format: date }
        number: { type: string }
        solution: { type: string }
        url: { type: string, description: "conseil-constitutionnel.fr verify link" }
        verify_note: { type: string }
        text: { type: string }
        truncated: { type: boolean }
        text_length: { type: integer }
        quota: { $ref: "#/components/schemas/Quota" }

    AuthoritiesSearchResponse:
      type: object
      properties:
        query: { type: string }
        results:
          type: array
          items:
            type: object
            properties:
              id: { type: string, description: "CNILTEXT id (cnil) or decision number e.g. 26-DCC-149 (adlc)" }
              authority: { type: string, enum: [adlc, cnil] }
              doc_type: { type: string, description: "Source-driven, no fixed enum" }
              title: { type: string }
              date: { type: string, format: date, description: "Decision date" }
              number: { type: string }
              url: { type: string, description: "autoritedelaconcurrence.fr or legifrance.gouv.fr verify link" }
              snippet: { type: string }
        meta:
          type: object
          properties:
            source: { type: string, enum: [adlc_cnil_amf] }
            count: { type: integer }
            limit: { type: integer }
            offset: { type: integer }
            has_more: { type: boolean }
            note: { type: string }
            quota: { $ref: "#/components/schemas/Quota" }

    AuthoritiesDocument:
      type: object
      properties:
        id: { type: string }
        authority: { type: string, enum: [adlc, cnil] }
        doc_type: { type: string }
        title: { type: string }
        date: { type: string, format: date }
        number: { type: string }
        url: { type: string, description: "official verify link" }
        verify_note: { type: string }
        text: { type: string }
        truncated: { type: boolean }
        text_length: { type: integer }
        quota: { $ref: "#/components/schemas/Quota" }

    CircularsSearchResponse:
      type: object
      properties:
        query: { type: string }
        results:
          type: array
          items:
            type: object
            properties:
              id: { type: string, description: "ID_CIRCULAIRE" }
              doc_type:
                type: string
                enum: [organisation_services, directives_ministre, interpretation_juridique, autre]
              title: { type: string }
              date: { type: string, format: date, description: "Date de signature" }
              number: { type: string, description: "NOR number, fallback numéro interne" }
              ministry: { type: string, description: "Émetteur (auteur or domaine), may be absent" }
              url: { type: string, description: "circulaires.legifrance.gouv.fr verify link (PDF)" }
              full_text: { type: boolean, description: "true if snippet/text come from the extracted PDF body, false if metadata only" }
              snippet: { type: string, description: "PDF body when full_text is true, résumé/mots-clés otherwise" }
        meta:
          type: object
          properties:
            source: { type: string, enum: [dila_circulaires] }
            count: { type: integer }
            limit: { type: integer }
            offset: { type: integer }
            has_more: { type: boolean }
            note: { type: string }
            quota: { $ref: "#/components/schemas/Quota" }

    CircularsDocument:
      type: object
      properties:
        id: { type: string }
        doc_type:
          type: string
          enum: [organisation_services, directives_ministre, interpretation_juridique, autre]
        title: { type: string }
        date: { type: string, format: date }
        number: { type: string }
        ministry: { type: string }
        url: { type: string, description: "circulaires.legifrance.gouv.fr verify link (PDF)" }
        full_text: { type: boolean, description: "true if text is the extracted PDF body, false if metadata only" }
        verify_note: { type: string }
        text: { type: string, description: "PDF body when full_text is true, résumé/mots-clés/destinataire otherwise" }
        truncated: { type: boolean }
        text_length: { type: integer }
        quota: { $ref: "#/components/schemas/Quota" }

    Error:
      type: object
      properties:
        error: { type: string }
        message: { type: string }
        kind: { type: string }
        limit: { type: integer }

security:
  - bearerAuth: []
  - apiKeyAuth: []
