{
  "openapi": "3.1.0",
  "info": {
    "title": "OGQ OPD (Open Physical Data) API",
    "version": "0.1.0",
    "description": "로봇·피지컬 AI 학습용 데이터셋 업로드 API. 이 문서에는 **실제로 배포된 엔드포인트만** 싣는다 — 문서에만 있고 서버에 없는 경로가 생기지 않도록 레포 가드(`src/lib/opd/openapi-contract.test.ts`)가 이 파일과 라우트 파일을 대조한다. 인증은 OID 세션 쿠키이며, 데이터셋은 **소유자 본인만** 읽고 쓸 수 있다. 모든 상태 전이는 해시체인 감사 원장(`opd_upload_audit_log`)에 기록되고, 기록 없이 상태만 바뀌는 경로는 존재하지 않는다. This spec lists only endpoints that are actually deployed.",
    "contact": {
      "email": "dev@ogq.me"
    },
    "license": {
      "name": "Proprietary",
      "url": "https://stg.nova.ogq.me/policies"
    }
  },
  "servers": [
    {
      "url": "https://stg.nova.ogq.me",
      "description": "Staging (current)"
    }
  ],
  "tags": [
    {
      "name": "datasets",
      "description": "데이터셋 생성·조회·상태 전이"
    },
    {
      "name": "uploads",
      "description": "S3 멀티파트 업로드(재개 가능)"
    },
    {
      "name": "consent",
      "description": "동의 철회 — 어느 단계에서든 즉시 판매 중지"
    },
    {
      "name": "ops",
      "description": "운영 점검"
    }
  ],
  "components": {
    "securitySchemes": {
      "oidSession": {
        "type": "apiKey",
        "in": "cookie",
        "name": "OID_SESSION",
        "description": "OID 로그인 세션 쿠키. 요청 주체는 쿠키에서만 결정되며 본문의 사용자 필드는 신뢰하지 않는다."
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "string",
            "description": "기계가 분기할 사유 코드. login_required · forbidden · not_found · invalid_dataset_id · invalid_body · invalid_params · state_locked · transition_rejected 등"
          },
          "reason": {
            "type": "string"
          },
          "state": {
            "$ref": "#/components/schemas/UploadState"
          }
        }
      },
      "UploadState": {
        "type": "string",
        "description": "업로드 상태머신. 현재 상태는 별도 컬럼이 아니라 감사 원장 마지막 항목의 to 값이다.",
        "enum": [
          "DRAFT",
          "UPLOADING",
          "SCANNING",
          "VALIDATING",
          "REJECTED",
          "NEEDS_FIX",
          "PII_PROCESSING",
          "SCORING",
          "PUBLISHED",
          "SUSPENDED",
          "TAKEDOWN"
        ]
      },
      "DatasetCreated": {
        "type": "object",
        "required": ["datasetId", "state"],
        "properties": {
          "datasetId": {
            "type": "string",
            "examples": ["opd_9F3A1C2D4E5B6A7C8D9E0F1A"]
          },
          "state": {
            "$ref": "#/components/schemas/UploadState"
          },
          "auditHash": {
            "type": ["string", "null"],
            "description": "감사 체인의 마지막 항목 해시"
          },
          "reused": {
            "type": "boolean",
            "description": "Idempotency-Key 재시도로 기존 데이터셋을 그대로 돌려줬는지"
          }
        }
      },
      "AuditEntry": {
        "type": "object",
        "required": ["seq", "from", "to", "event", "at", "hash"],
        "properties": {
          "seq": {
            "type": "integer"
          },
          "from": {
            "$ref": "#/components/schemas/UploadState"
          },
          "to": {
            "$ref": "#/components/schemas/UploadState"
          },
          "event": {
            "type": "string"
          },
          "at": {
            "type": "string",
            "format": "date-time"
          },
          "hash": {
            "type": "string"
          }
        }
      },
      "DatasetSnapshot": {
        "type": "object",
        "required": ["datasetId", "state", "nextEvents", "chain", "history"],
        "properties": {
          "datasetId": {
            "type": "string"
          },
          "state": {
            "$ref": "#/components/schemas/UploadState"
          },
          "nextEvents": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "지금 상태에서 받을 수 있는 이벤트"
          },
          "chain": {
            "type": "object",
            "required": ["valid"],
            "properties": {
              "valid": {
                "type": "boolean"
              },
              "brokenAt": {
                "type": ["integer", "null"]
              },
              "head": {
                "type": ["string", "null"]
              }
            }
          },
          "history": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AuditEntry"
            }
          }
        }
      },
      "Health": {
        "type": "object",
        "required": ["table", "exists"],
        "properties": {
          "table": {
            "type": "string",
            "examples": ["opd_upload_audit_log"]
          },
          "exists": {
            "type": "boolean"
          },
          "rows": {
            "type": ["integer", "null"]
          },
          "error": {
            "type": "string"
          }
        }
      },
      "QualityScore": {
        "type": "object",
        "description": "검증 품질 스코어. `unverifiable`(검증 수단이 없던 결과)은 **비율의 분자에도 분모에도 넣지 않는다** — 통과가 아니기 때문이다. 대신 몇 개를 못 쟀는지 `unverifiable` 과 `unverifiableRuleIds` 로 함께 낸다. 잰 규칙이 0 줄이면 `ratio` 는 `null` 이다(0 점이 아니라 «점수를 낼 수 없음»).",
        "required": [
          "total",
          "pass",
          "fail",
          "skip",
          "unverifiable",
          "measured",
          "ratio",
          "unverifiableRuleIds",
          "unmeasuredReason"
        ],
        "properties": {
          "total": {
            "type": "integer",
            "description": "결과 줄 수. `pass+fail+skip+unverifiable` 와 항상 같다."
          },
          "pass": {
            "type": "integer"
          },
          "fail": {
            "type": "integer"
          },
          "skip": {
            "type": "integer",
            "description": "이 데이터셋에 해당 없는 규칙."
          },
          "unverifiable": {
            "type": "integer",
            "description": "검사할 수단이 없던 결과 줄 수. 통과가 아니다."
          },
          "measured": {
            "type": "integer",
            "description": "`ratio` 의 분모 = `pass + fail`."
          },
          "ratio": {
            "type": ["number", "null"],
            "description": "`pass / (pass + fail)`. `measured` 가 0 이면 `null`."
          },
          "unverifiableRuleIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "못 잰 규칙 ID(중복 제거·정렬)."
          },
          "unmeasuredReason": {
            "type": ["string", "null"],
            "enum": ["no_measured_rule", null],
            "description": "`ratio` 가 `null` 인 사유. 잴 수 있었으면 `null`."
          }
        }
      },
      "HfDatasetCardUnresolved": {
        "type": "object",
        "description": "카드에 넣지 못한 frontmatter 필드와 그 사유. 빈 값으로 채우지 않고 «왜 못 냈는지»를 남긴다.",
        "required": ["field", "reason"],
        "properties": {
          "field": {
            "type": "string",
            "description": "HF 카드 frontmatter 필드 이름."
          },
          "reason": {
            "type": "string",
            "description": "못 낸 사유 코드(예: `no_license_meta`)."
          }
        }
      },
      "HfDatasetCard": {
        "type": "object",
        "description": "Hugging Face Hub 호환 데이터셋 카드. `frontmatter` 는 README.md 맨 위 YAML 블록이 될 객체이고 `readme` 는 그 블록과 본문을 합친 전문이다. **근거가 없는 필드는 채우지 않고** `unresolved` 로 낸다 — 특히 라이선스는 OPD 등급이 Hub 식별자가 아니므로 `other` + `license_name` 으로만 적고, 라이선스 메타가 없으면 `license` 키 자체가 없다. `datasetInfos` 는 레거시 `dataset_infos.json` 내용이며 근거가 없으면 `null` 이다(빈 객체가 아니다).",
        "required": ["datasetId", "format", "readme", "frontmatter", "datasetInfos", "unresolved"],
        "properties": {
          "datasetId": {
            "type": "string"
          },
          "format": {
            "type": "string",
            "enum": ["hf-card"]
          },
          "readme": {
            "type": "string",
            "description": "`---` frontmatter + 본문을 합친 README.md 전문."
          },
          "frontmatter": {
            "type": "object",
            "additionalProperties": true,
            "description": "YAML 블록이 될 객체. 근거가 없는 필드는 여기에 **없다**."
          },
          "datasetInfos": {
            "type": ["object", "null"],
            "additionalProperties": true,
            "description": "레거시 dataset_infos.json 내용. 근거가 없으면 null."
          },
          "unresolved": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/HfDatasetCardUnresolved"
            },
            "description": "못 낸 필드 + 사유. 빈 배열이면 전부 근거가 있었다는 뜻이다."
          }
        }
      },
      "PiiCoverage": {
        "type": "object",
        "description": "PII 단계의 검사 범위 — 「인물 포함 여부」를 누가 확인했는지. 얼굴 검출기가 아직 없어, 업로더가 「인물 없음」이라 신고하면 어떤 규칙도 검증 불가를 내지 않는다. 그 경우를 unexamined 가 드러낸다.",
        "required": [
          "detectorAvailable",
          "declaredPerson",
          "declaredPrivateSpace",
          "consentPersonSubjects",
          "declaredMasking",
          "examinedBy",
          "unexamined"
        ],
        "properties": {
          "detectorAvailable": {
            "type": "boolean",
            "description": "이 실행에서 얼굴 검출기 능력이 붙어 있었는가."
          },
          "declaredPerson": {
            "type": ["boolean", "null"],
            "description": "업로더 신고 — 인물 포함. meta/ogq.json 이 없으면 null 이며 「아니오」가 아니다."
          },
          "declaredPrivateSpace": {
            "type": ["boolean", "null"],
            "description": "업로더 신고 — 사유공간 포함. 파일이 없으면 null."
          },
          "consentPersonSubjects": {
            "type": ["integer", "null"],
            "description": "meta/consent.json 의 person 주체 수. 파일이 없으면 null 이며 0 이 아니다."
          },
          "declaredMasking": {
            "type": "string",
            "enum": [
              "applied_with_evidence",
              "applied_without_evidence",
              "declared_other",
              "undeclared"
            ],
            "description": "업로더가 신고한 마스킹 상태. 우리 검증 결과가 아니다."
          },
          "examinedBy": {
            "type": "string",
            "enum": ["detector", "declaration", "none"],
            "description": "detector = 기계가 볼 수 있었다 · declaration = 업로더 신고가 유일한 근거 · none = 아무도 확인하지 않았다."
          },
          "unexamined": {
            "type": "boolean",
            "description": "true 면 기계도 사람도 인물 여부를 확인하지 않았다. 「인물 없음」은 검증되지 않은 주장이다."
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "로그인 필요",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "데이터셋 소유자가 아니다",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "그런 데이터셋이 없다",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "BadRequest": {
        "description": "입력이 형식에 맞지 않는다",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Conflict": {
        "description": "지금 상태에서 허용되지 않는 전이",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "parameters": {
      "DatasetId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "opd_ 로 시작하는 데이터셋 ID"
      }
    }
  },
  "security": [
    {
      "oidSession": []
    }
  ],
  "paths": {
    "/api/opd/health": {
      "get": {
        "tags": ["ops"],
        "summary": "감사 원장 존재 확인",
        "description": "감사 원장 표를 보증하고 행 수를 돌려준다. 인증 불필요. 표를 세우지 못하면 503 과 error 를 준다 — 「없다」와 「모른다」를 구분하기 위해서다.",
        "operationId": "opdHealth",
        "security": [],
        "responses": {
          "200": {
            "description": "표가 있다",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          },
          "503": {
            "description": "표를 보증하지 못했다",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          }
        }
      }
    },
    "/api/opd/datasets": {
      "post": {
        "tags": ["datasets"],
        "summary": "데이터셋 생성(업로드 개시)",
        "description": "DRAFT → UPLOADING 전이를 감사 체인의 제네시스 항목으로 남기고 소유자를 확정한다. Idempotency-Key 를 주면 같은 키의 재시도가 같은 데이터셋을 돌려주고 새로 만들지 않는다.",
        "operationId": "createOpdDataset",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "재시도 키. 저장되지 않고 데이터셋 ID 유도에만 쓰인다."
          }
        ],
        "responses": {
          "200": {
            "description": "같은 Idempotency-Key 로 이미 만들어져 있다",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DatasetCreated"
                }
              }
            }
          },
          "201": {
            "description": "생성됨",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DatasetCreated"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/api/opd/datasets/{id}": {
      "get": {
        "tags": ["datasets"],
        "summary": "상태 + 감사 체인 조회",
        "operationId": "getOpdDataset",
        "parameters": [
          {
            "$ref": "#/components/parameters/DatasetId"
          },
          {
            "name": "format",
            "in": "query",
            "required": false,
            "description": "응답 표현. 생략하면 상태 스냅샷, `hf-card` 면 HF 데이터셋 카드.",
            "schema": {
              "type": "string",
              "enum": ["hf-card"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "현재 상태와 전이 이력",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/DatasetSnapshot"
                    },
                    {
                      "$ref": "#/components/schemas/HfDatasetCard"
                    }
                  ],
                  "description": "`format` 을 생략하면 DatasetSnapshot, `format=hf-card` 면 HfDatasetCard."
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "description": "기본 표현은 상태 + 감사 체인이다. `format=hf-card` 를 주면 같은 자원을 Hugging Face Hub 데이터셋 카드(README.md frontmatter + 레거시 dataset_infos.json)로 낸다. 카드의 품질 점수는 원장에 저장된 검증 줄에서 읽으며, 검증 이력이 없으면 «미측정»이다(0 점이 아니다). 이 API 는 Hub 에 업로드하지 않는다 — 생성된 카드를 돌려줄 뿐이다."
      }
    },
    "/api/opd/datasets/{id}/uploads": {
      "post": {
        "tags": ["uploads"],
        "summary": "멀티파트 업로드 생성",
        "operationId": "createOpdUpload",
        "parameters": [
          {
            "$ref": "#/components/parameters/DatasetId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["path"],
                "properties": {
                  "path": {
                    "type": "string",
                    "description": "데이터셋 안의 파일 경로"
                  },
                  "contentType": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "업로드 세션 생성",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["uploadId", "objectKey", "minPartSize"],
                  "properties": {
                    "uploadId": {
                      "type": "string"
                    },
                    "objectKey": {
                      "type": "string",
                      "description": "S3 오브젝트 키(서버가 path 로부터 유도한다)"
                    },
                    "minPartSize": {
                      "type": "integer",
                      "description": "파트 최소 크기(바이트)"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/api/opd/datasets/{id}/uploads/parts": {
      "post": {
        "tags": ["uploads"],
        "summary": "파트 업로드 URL 발급",
        "operationId": "signOpdUploadPart",
        "parameters": [
          {
            "$ref": "#/components/parameters/DatasetId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["path", "uploadId", "partNumber"],
                "properties": {
                  "path": {
                    "type": "string",
                    "description": "데이터셋 안의 파일 경로. 서버가 오브젝트 키를 유도한다"
                  },
                  "uploadId": {
                    "type": "string"
                  },
                  "partNumber": {
                    "type": "integer",
                    "minimum": 1
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "서명된 업로드 URL",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "uploadUrl": {
                      "type": "string"
                    },
                    "partNumber": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "get": {
        "tags": ["uploads"],
        "summary": "업로드된 파트 목록(재개용)",
        "operationId": "listOpdUploadParts",
        "parameters": [
          {
            "$ref": "#/components/parameters/DatasetId"
          },
          {
            "name": "path",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "uploadId",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "지금까지 올라간 파트",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["parts", "uploadedBytes"],
                  "properties": {
                    "parts": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "partNumber": {
                            "type": "integer"
                          },
                          "size": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "uploadedBytes": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/api/opd/datasets/{id}/uploads/complete": {
      "post": {
        "tags": ["uploads"],
        "summary": "멀티파트 업로드 종료",
        "operationId": "completeOpdUpload",
        "parameters": [
          {
            "$ref": "#/components/parameters/DatasetId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["path", "uploadId"],
                "properties": {
                  "path": {
                    "type": "string"
                  },
                  "uploadId": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "결합 완료",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "parts": {
                      "type": "integer"
                    },
                    "path": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/api/opd/datasets/{id}/validate": {
      "post": {
        "tags": ["datasets"],
        "summary": "규격 검증 실행",
        "description": "구조·무결성·권리·품질 규칙을 돌려 PASSED / NEEDS_FIX / REJECTED 판정을 내고 그 판정에 맞는 전이를 감사 원장에 남긴다.",
        "operationId": "validateOpdDataset",
        "parameters": [
          {
            "$ref": "#/components/parameters/DatasetId"
          }
        ],
        "responses": {
          "200": {
            "description": "검증 결과",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "datasetId": {
                      "type": "string"
                    },
                    "state": {
                      "$ref": "#/components/schemas/UploadState"
                    },
                    "verdict": {
                      "type": "string",
                      "enum": ["PASSED", "NEEDS_FIX", "REJECTED"]
                    },
                    "score": {
                      "$ref": "#/components/schemas/QualityScore"
                    },
                    "piiCoverage": {
                      "$ref": "#/components/schemas/PiiCoverage"
                    },
                    "findings": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    },
    "/api/opd/datasets/{id}/consent-revoke": {
      "post": {
        "tags": ["consent"],
        "summary": "동의 철회",
        "description": "어느 단계에서든 즉시 SUSPENDED 로 간다(종결 상태 제외). 철회 사실도 감사 원장에 남는다.",
        "operationId": "revokeOpdConsent",
        "parameters": [
          {
            "$ref": "#/components/parameters/DatasetId"
          }
        ],
        "responses": {
          "200": {
            "description": "철회 반영",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "datasetId": {
                      "type": "string"
                    },
                    "state": {
                      "$ref": "#/components/schemas/UploadState"
                    },
                    "auditHash": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      }
    }
  }
}
