{
  "openapi": "3.0.3",
  "info": {
    "title": "DCinside RTB API",
    "description": "Wire contract for DCinside scoring-only rehearsal. Real queue candidates go to Surf while operators continue publishing; Surf does not publish. Raw Model 0924 scores remain complete. Same-event deduplication compares scored candidates only with independent actual last 20 Main/Light published windows. New cycles durably prepare scores and exact evidence, then wait within the remaining server budget for needed background work to settle before freezing a final response. Facts completed during that wait can appear in the same cycle. Background work prioritizes higher scores among eligible comparisons; validated full-evidence unknowns remain unchecked until evidence/model/prompt changes. Unknown context or evidence stays unchecked. Five-minute cycles and a 60-second client deadline including at most one retry apply. Completed POST retries and GET recovery remain immutable; unfinished prepared retries resume without repeating scoring. This specification does not certify deployment readiness, whole-queue completion or measured end-to-end latency. For newly finalized cycles, each tier ranks every scored candidate except confirmed duplicates. Unchecked candidates remain eligible, including unknown, unfinished, timed-out, unavailable and disabled assessments; unchecked is a diagnostic subset of ranked and must not be subtracted for selection. Status and counters still describe assessment, not eligibility or proof of uniqueness. Historical completed responses retain their original ranking policy.",
    "version": "1.3.0-draft"
  },
  "servers": [
    {
      "url": "https://dcinside-rtb-api.stg.kr.ask.surf",
      "description": "Planned rehearsal host. Cut over only after deployment and reachability verification; this specification does not declare the endpoint available."
    }
  ],
  "security": [
    {
      "ApiKey": []
    }
  ],
  "tags": [
    {
      "name": "Scoring",
      "description": "Cycle scoring and retrieval for the rehearsal: fixed five-minute cycles, every eligible pending candidate refreshed each cycle, and 60 seconds from sending including at most one retry."
    },
    {
      "name": "Images",
      "description": "Agreed once-sent HTTPS JPEG transfer in the v1.3 layout. Upload timing never gates raw scoring."
    },
    {
      "name": "Outcomes",
      "description": "Not in scope for trial. Reference for later outcomes-endpoint integration. Daily operator outcomes may be shared separately by agreement."
    },
    {
      "name": "Weights",
      "description": "Not in scope for trial. Reference for later client-facing weight-management integration. Surf configures the initial trial model and weights."
    },
    {
      "name": "Health",
      "description": "Service information; not proof of model readiness, durable state or alert delivery."
    }
  ],
  "paths": {
    "/v1/cycles": {
      "post": {
        "operationId": "scoreCycle",
        "tags": [
          "Scoring"
        ],
        "summary": "Score a Cycle",
        "description": "Send new candidates, complete pending engagement, admin snapshots, removals and independent actual published last 20 Main/Light windows. Published references reuse received content; for never-delivered publications, include title/body/image metadata once in window entries and upload JPEGs through the existing image route. Every eligible pending raw score remains in results; before extracted_at plus 15 minutes, return waiting. After raw scoring, durably prepare scores and exact candidate/window evidence, release the database writer, and wait within the remaining server budget for needed background work to settle before freezing the response. Reserve time for finalization; finding one usable candidate does not end the wait. Facts completed during waiting can appear in the same cycle. Full-evidence unknowns and unavailable evidence can remain unchecked; transient retry cooldown is not settlement. The background worker owns provider calls, so response provider_calls remains zero even when calls overlap the wait. Higher-scored eligible work runs first; one positive published match can stop remaining comparisons needed only for its tier. No candidate-to-candidate checks or candidate quota. An unchanged retry resumes unfinished preparation without repeated ingestion or scoring; completed POST retries and GET remain immutable. Missing published evidence reports published_content_missing; incomplete work reports background_pending. Newly prepared scored_at records raw score completion before deduplication waiting, not response delivery. Five-minute rehearsal cadence and 60 seconds from sending including at most one retry remain unchanged. First-score target: at least 99% within two cycles after original queue entry plus 15 minutes. Saved/hidden decisions remain in the first-score denominator; immediate deleted/aged_out removals before the mark retain the early-removal exclusion. First scoring does not end retention. Under the operator-independent sender policy, saved/hidden labels stay client-side; continue engagement every cycle until client-delivered deleted/aged_out removal under its 24-hour age filter. There is no automatic server expiry. Saved/hidden enum values remain accepted for compatibility and still remove posts if sent. Received removals take effect before scoring; resending removed identities cannot revive them. Retrospective backfill is not yet agreed. Include between-cycle entrants with original extracted_at and source unknown when unavailable. The earlier first-score-or-25-minute hold was never activated; the proposed 2026-10-15 00:00 KST cycle 20261015T0000 switch is unconfirmed. Record the actual switch without reclassifying history. Hidden alone does not veto client picks; the client skips all current-board posts, beyond the supplied last-20 tier windows. Scores still use cycle-time evidence, not reconstructed decision-time evidence. Verify deployed cold/warm coverage and latency before rehearsal; whole-queue readiness is not guaranteed. For newly finalized cycles, each tier ranks every scored candidate except confirmed duplicates. Unchecked candidates remain eligible, including unknown, unfinished, timed-out, unavailable and disabled assessments; unchecked is a diagnostic subset of ranked and must not be subtracted for selection. Status and counters still describe assessment, not eligibility or proof of uniqueness. Historical completed responses retain their original ranking policy.",
        "parameters": [
          {
            "name": "Content-Encoding",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "gzip"
              ]
            },
            "description": "Optional compression. Set to gzip only for a gzip-compressed body; plain JSON is also supported."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CycleRequest"
              },
              "example": {
                "cycle_id": "20260929T0205",
                "cycle_time": "2026-09-29T02:05:00+09:00",
                "candidates": [
                  {
                    "gall_id": "example_gallery",
                    "post_no": 31900001,
                    "title": "Example original title",
                    "created_at": "2026-09-29T01:40:12+09:00",
                    "extracted_at": "2026-09-29T01:49:40+09:00",
                    "source": "keyword",
                    "manual_flag": 0,
                    "images_in_post": 2
                  }
                ],
                "engagement": [
                  {
                    "gall_id": "example_gallery",
                    "post_no": 31900001,
                    "observed_at": "2026-09-29T02:04:00+09:00",
                    "status": "ok",
                    "views": 412,
                    "up": 9,
                    "down": 1,
                    "comment_cnt": 6,
                    "rtb_vote": 0
                  },
                  {
                    "gall_id": "example_gallery",
                    "post_no": 31899120,
                    "observed_at": "2026-09-29T02:04:00+09:00",
                    "status": "ok",
                    "views": 1875,
                    "up": null,
                    "down": null,
                    "comment_cnt": 21,
                    "rtb_vote": 0
                  }
                ],
                "candidate_scores": [
                  {
                    "gall_id": "example_gallery",
                    "post_no": 31900001,
                    "snapshot_at": "2026-09-29T02:05:00+09:00",
                    "recommend_up_cnt": 3,
                    "recommend_up_cnt_m": 2,
                    "recommend_up_cnt_a": 1,
                    "comment_cnt": 2,
                    "comment_cnt_m": 1,
                    "comment_cnt_a": 0,
                    "hit_cnt": 5,
                    "hit_cnt_m": 3,
                    "hit_cnt_a": 1,
                    "dcbest_cnt": 0,
                    "dcbest_cnt_m": 0,
                    "dcbest_cnt_a": 0,
                    "memo_size": 1840,
                    "upimg_cnt": 2,
                    "upimg_height": 2410
                  }
                ],
                "removals": [
                  {
                    "gall_id": "example_gallery",
                    "post_no": 31898050,
                    "reason": "deleted",
                    "decided_at": "2026-09-29T02:01:10+09:00"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Complete raw scores and the immutable deduplication report for this cycle. Provider failure can return 200 with raw scores and explicit unavailable/unchecked deduplication.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CycleResponse"
                },
                "examples": {
                  "first_scores": {
                    "summary": "Illustrative: newly delivered candidate is already at least 15 minutes past queue entry",
                    "value": {
                      "cycle_id": "20260929T0205",
                      "cycle_time": "2026-09-29T02:05:00+09:00",
                      "received_at": "2026-09-29T02:05:03+09:00",
                      "scored_at": "2026-09-29T02:05:09+09:00",
                      "model_version": "m0924-weight0",
                      "weights_version": "w-1",
                      "counts": {
                        "candidates": 1,
                        "engagement": 2,
                        "candidate_scores": 1,
                        "removals": 1,
                        "scored_now": 2,
                        "waiting": 0,
                        "not_scored": 0,
                        "results": 2
                      },
                      "missing_feature_share": 0.11,
                      "warnings": [],
                      "results": [
                        {
                          "gall_id": "example_gallery",
                          "post_no": 31899120,
                          "status": "scored",
                          "score": 0.62,
                          "scored_at_cycle": "2026-09-29T02:05:00+09:00"
                        },
                        {
                          "gall_id": "example_gallery",
                          "post_no": 31900001,
                          "status": "scored",
                          "score": 0.51,
                          "scored_at_cycle": "2026-09-29T02:05:00+09:00"
                        }
                      ],
                      "deduplication": {
                        "status": "unavailable",
                        "model": null,
                        "prompt_version": null,
                        "tiers": {
                          "main": {
                            "status": "unavailable",
                            "context": "candidate_only",
                            "ranked": [
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31899120
                              },
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31900001
                              }
                            ],
                            "duplicates": [],
                            "unchecked": [
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31899120
                              },
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31900001
                              }
                            ],
                            "warnings": [
                              "provider_not_configured",
                              "published_window_missing"
                            ]
                          },
                          "light": {
                            "status": "unavailable",
                            "context": "candidate_only",
                            "ranked": [
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31899120
                              },
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31900001
                              }
                            ],
                            "duplicates": [],
                            "unchecked": [
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31899120
                              },
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31900001
                              }
                            ],
                            "warnings": [
                              "provider_not_configured",
                              "published_window_missing"
                            ]
                          }
                        }
                      }
                    }
                  },
                  "refreshed_and_not_scored": {
                    "summary": "Illustrative proposal: scores refresh in the next cycle; one row cannot be scored",
                    "value": {
                      "cycle_id": "20260929T0210",
                      "cycle_time": "2026-09-29T02:10:00+09:00",
                      "received_at": "2026-09-29T02:10:02+09:00",
                      "scored_at": "2026-09-29T02:10:03+09:00",
                      "model_version": "m0924-weight0",
                      "weights_version": "w-1",
                      "counts": {
                        "candidates": 1,
                        "engagement": 3,
                        "candidate_scores": 3,
                        "removals": 0,
                        "scored_now": 2,
                        "waiting": 0,
                        "not_scored": 1,
                        "results": 3
                      },
                      "missing_feature_share": 0.0,
                      "warnings": [],
                      "results": [
                        {
                          "gall_id": "example_gallery",
                          "post_no": 31899120,
                          "status": "scored",
                          "score": 0.67,
                          "scored_at_cycle": "2026-09-29T02:10:00+09:00"
                        },
                        {
                          "gall_id": "example_gallery",
                          "post_no": 31900001,
                          "status": "scored",
                          "score": 0.56,
                          "scored_at_cycle": "2026-09-29T02:10:00+09:00"
                        },
                        {
                          "gall_id": "example_gallery",
                          "post_no": 31900044,
                          "status": "not_scored",
                          "score": null,
                          "reason": "missing_clock"
                        }
                      ],
                      "deduplication": {
                        "status": "unavailable",
                        "model": null,
                        "prompt_version": null,
                        "tiers": {
                          "main": {
                            "status": "unavailable",
                            "context": "candidate_only",
                            "ranked": [
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31899120
                              },
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31900001
                              }
                            ],
                            "duplicates": [],
                            "unchecked": [
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31899120
                              },
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31900001
                              }
                            ],
                            "warnings": [
                              "provider_not_configured",
                              "published_window_missing"
                            ]
                          },
                          "light": {
                            "status": "unavailable",
                            "context": "candidate_only",
                            "ranked": [
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31899120
                              },
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31900001
                              }
                            ],
                            "duplicates": [],
                            "unchecked": [
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31899120
                              },
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31900001
                              }
                            ],
                            "warnings": [
                              "provider_not_configured",
                              "published_window_missing"
                            ]
                          }
                        }
                      }
                    }
                  },
                  "waiting_before_fifteen_minutes": {
                    "summary": "Illustrative: candidate entering at 02:03:40 waits until the 02:20 cycle",
                    "value": {
                      "cycle_id": "20260929T0205",
                      "cycle_time": "2026-09-29T02:05:00+09:00",
                      "received_at": "2026-09-29T02:05:03+09:00",
                      "scored_at": "2026-09-29T02:05:09+09:00",
                      "model_version": "m0924-weight0",
                      "weights_version": "w-1",
                      "counts": {
                        "candidates": 1,
                        "engagement": 2,
                        "candidate_scores": 1,
                        "removals": 1,
                        "scored_now": 1,
                        "waiting": 1,
                        "not_scored": 0,
                        "results": 2
                      },
                      "missing_feature_share": 0.11,
                      "warnings": [],
                      "results": [
                        {
                          "gall_id": "example_gallery",
                          "post_no": 31899120,
                          "status": "scored",
                          "score": 0.62,
                          "scored_at_cycle": "2026-09-29T02:05:00+09:00"
                        },
                        {
                          "gall_id": "example_gallery",
                          "post_no": 31900001,
                          "status": "waiting",
                          "due_cycle": "2026-09-29T02:20:00+09:00"
                        }
                      ],
                      "deduplication": {
                        "status": "unavailable",
                        "model": null,
                        "prompt_version": null,
                        "tiers": {
                          "main": {
                            "status": "unavailable",
                            "context": "candidate_only",
                            "ranked": [
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31899120
                              }
                            ],
                            "duplicates": [],
                            "unchecked": [
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31899120
                              }
                            ],
                            "warnings": [
                              "provider_not_configured",
                              "published_window_missing"
                            ]
                          },
                          "light": {
                            "status": "unavailable",
                            "context": "candidate_only",
                            "ranked": [
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31899120
                              }
                            ],
                            "duplicates": [],
                            "unchecked": [
                              {
                                "gall_id": "example_gallery",
                                "post_no": 31899120
                              }
                            ],
                            "warnings": [
                              "provider_not_configured",
                              "published_window_missing"
                            ]
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "503": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/v1/scores/{cycle_id}": {
      "get": {
        "operationId": "getCycleScores",
        "tags": [
          "Scoring"
        ],
        "summary": "Get Cycle Scores",
        "description": "Retrieve the stored final result of a particular cycle without scoring again or refreshing its deduplication report. Completed POST retries and GET stay identical; subsequent background facts, changed evidence, scores or removals affect a new cycle only. A recovered late response is not eligible for its original decision cycle. Completed responses persist across restarts on the durable volume. A receipt or prepared scores may exist without a final response while waiting or after interruption; GET returns 404 and does not expose unfinished preparation. Replay the unchanged request to resume that preparation without repeated ingestion or scoring. Verify volume recovery before rehearsal.",
        "parameters": [
          {
            "name": "cycle_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{8}T\\d{4}$",
              "example": "20260929T0205"
            },
            "description": "The cycle id sent in the original call."
          }
        ],
        "responses": {
          "200": {
            "description": "The response stored for this cycle.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CycleResponse"
                },
                "example": {
                  "cycle_id": "20260929T0205",
                  "cycle_time": "2026-09-29T02:05:00+09:00",
                  "received_at": "2026-09-29T02:05:03+09:00",
                  "scored_at": "2026-09-29T02:05:09+09:00",
                  "model_version": "m0924-weight0",
                  "weights_version": "w-1",
                  "counts": {
                    "candidates": 1,
                    "engagement": 2,
                    "candidate_scores": 1,
                    "removals": 1,
                    "scored_now": 2,
                    "waiting": 0,
                    "not_scored": 0,
                    "results": 2
                  },
                  "missing_feature_share": 0.11,
                  "warnings": [],
                  "results": [
                    {
                      "gall_id": "example_gallery",
                      "post_no": 31899120,
                      "status": "scored",
                      "score": 0.62,
                      "scored_at_cycle": "2026-09-29T02:05:00+09:00"
                    },
                    {
                      "gall_id": "example_gallery",
                      "post_no": 31900001,
                      "status": "scored",
                      "score": 0.51,
                      "scored_at_cycle": "2026-09-29T02:05:00+09:00"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/v1/images/{gall_id}/{post_no}/{NN}": {
      "put": {
        "operationId": "putImage",
        "tags": [
          "Images"
        ],
        "summary": "Upload an Image",
        "description": "Agreed HTTPS image PUT using original body positions from the v1.3 layout. Images may arrive one or two cycles later and raw scoring never waits for them; candidate image-count metadata is available before uploads. Identical-byte replay is supported. The 5 MiB image size protection is an engineering bound, not a business quota.",
        "parameters": [
          {
            "name": "gall_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "example": "example_gallery"
            },
            "description": "Gallery id."
          },
          {
            "name": "post_no",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "example": 31900001
            },
            "description": "Post number."
          },
          {
            "name": "NN",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^\\d{2,}$",
              "example": "01"
            },
            "description": "The image's position in the original body: one-based, zero-padded to at least two digits, gaps allowed. The v1.3 layout `<gall_id>/<post_no>/<NN>.jpg`."
          },
          {
            "name": "X-Content-SHA256",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^[0-9a-f]{64}$"
            },
            "description": "Hex SHA-256 of the body. When present we verify it, and a mismatch is rejected with 400."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "image/jpeg": {
              "schema": {
                "type": "string",
                "format": "binary",
                "description": "The JPEG, 1024 px on the long edge. Smaller originals are not upscaled."
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Stored."
          },
          "200": {
            "description": "Already stored with the same bytes."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          },
          "409": {
            "description": "An image already exists at this key with different bytes. The existing image is preserved.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": {
                    "code": "conflict",
                    "message": "image already exists with different bytes"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/outcomes": {
      "post": {
        "operationId": "postOutcomes",
        "tags": [
          "Outcomes"
        ],
        "summary": "Send Outcomes",
        "description": "Not in scope for trial. Reference endpoint for later integration: 1/6/24/48-hour board readings, hides, deletions, picker and score at placement. Daily operator outcomes may be shared separately by agreement during rehearsal; this endpoint is not a required trial call. Sampling ages differ from upload frequency. Payload and live-trial event-versus-hourly delivery remain to be agreed. The current handler replaces a placement record instead of merging partial readings, so current tests must send complete known snapshots. Records persist transactionally; this endpoint remains outside the trial integration scope.",
        "parameters": [
          {
            "name": "Content-Encoding",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "gzip"
              ]
            },
            "description": "Optional compression. Set to gzip only for a gzip-compressed body; plain JSON is also supported."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OutcomesRequest"
              },
              "example": {
                "outcomes": [
                  {
                    "placement_id": "example-4390001",
                    "gall_id": "example_gallery",
                    "post_no": 31899120,
                    "tier": "main",
                    "placed_at": "2026-09-29T02:40:00+09:00",
                    "picked_by": {
                      "type": "operator"
                    },
                    "score_at_placement": 0.62,
                    "views_at_placement": 1875,
                    "up_at_placement": 40,
                    "down_at_placement": 3,
                    "comment_at_placement": 21,
                    "readings": [
                      {
                        "age_h": 1,
                        "observed_at": "2026-09-29T03:40:00+09:00",
                        "views": 9120,
                        "up": 66,
                        "down": 8,
                        "comment_cnt": 30
                      }
                    ],
                    "hidden_at": null,
                    "deleted_at": null,
                    "deleted_by": null
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OutcomesResponse"
                },
                "example": {
                  "received": 1,
                  "created": 1,
                  "updated": 0
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-trial-scope": "reference-only"
      }
    },
    "/v1/weights": {
      "put": {
        "operationId": "putWeights",
        "tags": [
          "Weights"
        ],
        "summary": "Set Weights",
        "description": "Not in scope for trial. This client-facing management endpoint is reference material for later integration; Surf configures the initial trial weights. Proposed versioned weight changes without retraining, effective next cycle. Access and activation rules need confirmation. Scores must reflect their actual model and weights; the current cached-score implementation needs correction before client-driven changes are enabled. Non-negative weights with a positive views, comments or up weight are current validation rules, not client-agreed ranges.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WeightsRequest"
              },
              "example": {
                "version": "w-1",
                "weights": {
                  "views": 1,
                  "comments": 0,
                  "up": 0,
                  "downvote_share": 0
                },
                "note": "first trial: views only"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Accepted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WeightsAck"
                },
                "example": {
                  "version": "w-1",
                  "first_cycle_time": "2026-09-29T02:10:00+09:00"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-trial-scope": "reference-only"
      },
      "get": {
        "operationId": "getWeights",
        "tags": [
          "Weights"
        ],
        "summary": "Get Weights",
        "description": "Not in scope for trial. This client-facing management endpoint is reference material for later integration; Surf configures the initial trial weights. Inspect current and previous weight configurations. Verify score provenance before enabling client-driven changes: current version labels can accompany earlier cached scores. The intended starting model is delivered Model 0924 with down-vote weight 0.",
        "responses": {
          "200": {
            "description": "Current and previous weights.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WeightsState"
                },
                "example": {
                  "current": {
                    "version": "w-2",
                    "weights": {
                      "views": 1,
                      "comments": 0.5,
                      "up": 0.25,
                      "downvote_share": 0
                    },
                    "note": "add comments and up-votes",
                    "set_at": "2026-09-30T03:00:00+09:00",
                    "first_cycle_time": "2026-09-30T03:05:00+09:00"
                  },
                  "previous": {
                    "version": "w-1",
                    "weights": {
                      "views": 1,
                      "comments": 0,
                      "up": 0,
                      "downvote_share": 0
                    },
                    "note": "first trial: views only",
                    "set_at": "2026-09-29T02:02:11+09:00",
                    "first_cycle_time": "2026-09-29T02:05:00+09:00"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "x-trial-scope": "reference-only"
      }
    },
    "/v1/health": {
      "get": {
        "operationId": "getHealth",
        "tags": [
          "Health"
        ],
        "summary": "Health",
        "description": "Model/storage readiness and durable first receipt of the latest valid new cycle ID. Replays do not advance the receipt. Public endpoint; status does not establish notification delivery. Verify independent watchdog delivery with the rehearsal failure drill.",
        "responses": {
          "200": {
            "description": "Service state.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                },
                "example": {
                  "status": "ok",
                  "time": "2026-09-29T02:05:30+09:00",
                  "api_version": "1",
                  "model_version": "m0924-weight0",
                  "weights_version": "w-1",
                  "last_cycle_id": "20260929T0205",
                  "last_cycle_received_at": "2026-09-29T02:05:03+09:00"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        },
        "security": []
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKey": {
        "type": "apiKey",
        "name": "Authorization",
        "in": "header",
        "description": "Proposed API key sent as the whole Authorization header value. One long-lived key, handed privately to the partner contact and rotated on either side's request. Per-endpoint scopes are not currently enforced."
      }
    },
    "schemas": {
      "Candidate": {
        "type": "object",
        "properties": {
          "gall_id": {
            "type": "string",
            "minLength": 1,
            "description": "Post key, together with `post_no`.",
            "example": "example_gallery"
          },
          "post_no": {
            "type": "integer",
            "minimum": 1,
            "description": "Post number. Unique only within a gallery, so a post is always keyed by `gall_id` and `post_no` together.",
            "example": 31900001
          },
          "title": {
            "type": "string",
            "example": "Example original title",
            "description": "Original extraction-time title before operator edits. Delivered field: queue.subject. Feature preprocessing and missing-title handling must be verified with Model 0924; they are not client-defined API limits."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Post creation time, used for age handling. RFC 3339 with explicit offset; null is accepted by the current parser but cannot supply a valid age. Delivered field: queue.created_at.",
            "example": "2026-09-29T01:58:12+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$",
            "nullable": true
          },
          "extracted_at": {
            "type": "string",
            "format": "date-time",
            "description": "Original queue entry, including posts captured after an operator decision. A known clock is retained on retransmission; scoring starts at or after this time plus 15 minutes. Rehearsal requires a non-null clock.",
            "example": "2026-09-29T02:03:40+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$",
            "nullable": true
          },
          "source": {
            "type": "string",
            "enum": [
              "score",
              "setting",
              "keyword",
              "vote",
              "manual",
              "unknown"
            ],
            "example": "keyword",
            "description": "Original candidate source; use unknown when unavailable, including entrants captured between cycles."
          },
          "manual_flag": {
            "type": "integer",
            "enum": [
              0,
              1
            ],
            "example": 0,
            "description": "1 when registered by hand from external monitoring. Proposed metadata mapping from queue.manual_flag; confirm requiredness during payload review."
          },
          "images_in_post": {
            "type": "integer",
            "minimum": 0,
            "example": 2,
            "description": "Original post image count from v1.3 image_manifest.images_in_post. Supplies Model0924 upimg_cnt only when the latest as-of admin snapshot value is missing/null; an explicit snapshot zero or other count wins. Never derived from received upload bytes."
          },
          "image_positions": {
            "type": "array",
            "uniqueItems": true,
            "items": {
              "type": "integer",
              "minimum": 1
            },
            "example": [
              1,
              2
            ],
            "description": "Original one-based body positions expected for upload, preserving gaps for unavailable source images. Proposed mapping from v1.3 image_manifest.image_index."
          },
          "body_html": {
            "type": "string",
            "example": "<p>Example body</p>",
            "description": "Original body text/HTML, delivered as queue.body_html. Text is sent once. Confirm body representation and model use during integration; do not infer optional source content from this draft field."
          },
          "gallery_type": {
            "type": "string",
            "enum": [
              "main",
              "minor",
              "mini"
            ],
            "example": "minor",
            "description": "Gallery type metadata. Confirm whether it is supplied directly or resolved through a gallery registry, including new and unknown galleries."
          },
          "category_id": {
            "type": "integer",
            "example": 12,
            "description": "Gallery category identifier from DCinside. Confirm the registry and mapping; there is no client-agreed numeric category range of 1–47."
          }
        },
        "required": [
          "gall_id",
          "post_no",
          "title",
          "created_at",
          "extracted_at",
          "source",
          "manual_flag",
          "images_in_post"
        ],
        "description": "Proposed new-candidate wire shape based on delivered data. Send text once after queue entry. Required metadata fields need client payload review."
      },
      "EngagementRow": {
        "type": "object",
        "properties": {
          "gall_id": {
            "type": "string",
            "minLength": 1,
            "description": "Post key, together with `post_no`.",
            "example": "example_gallery"
          },
          "post_no": {
            "type": "integer",
            "minimum": 1,
            "description": "Post number. Unique only within a gallery, so a post is always keyed by `gall_id` and `post_no` together.",
            "example": 31900001
          },
          "observed_at": {
            "type": "string",
            "format": "date-time",
            "description": "Time these counters were observed, with explicit offset, at or before cycle_time. Preserve source precision. Feature-window and freshness rules must be verified with the model and the agreed cadence. Delivered field: engagement_ts.ts.",
            "example": "2026-09-29T02:04:00+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "status": {
            "type": "string",
            "enum": [
              "ok",
              "missing",
              "nomap"
            ],
            "example": "ok",
            "description": "Delivered observation state: ok, missing or nomap. The current adapter excludes missing/nomap rows from scoring. Confirm missing-data handling with the real model."
          },
          "views": {
            "type": "integer",
            "minimum": 0,
            "description": "Current view count. Keep null for an unknown reading. Surf can derive engagement history from successive observations; feature details must match the delivered model.",
            "example": 412,
            "nullable": true
          },
          "up": {
            "type": "integer",
            "minimum": 0,
            "description": "Recommendations, not the Send-to-Best counter. In v1 and v1.1 of the delivered files this column held the wrong counter, and v1.2 corrected it.",
            "example": 9,
            "nullable": true
          },
          "down": {
            "type": "integer",
            "minimum": 0,
            "description": "Down-votes. Feeds the down-vote share input.",
            "example": 1,
            "nullable": true
          },
          "comment_cnt": {
            "type": "integer",
            "minimum": 0,
            "description": "Comment count.",
            "example": 6,
            "nullable": true
          },
          "rtb_vote": {
            "type": "integer",
            "minimum": 0,
            "description": "Send-to-Best votes, distinct from recommendations (up). This additional wire field is proposed for review, not a separate client requirement.",
            "example": 0,
            "nullable": true
          }
        },
        "required": [
          "gall_id",
          "post_no",
          "observed_at",
          "status",
          "views",
          "up",
          "down",
          "comment_cnt",
          "rtb_vote"
        ],
        "description": "Current counters of one pending post, as read from the gallery."
      },
      "CandidateScoreRow": {
        "type": "object",
        "properties": {
          "gall_id": {
            "type": "string",
            "minLength": 1,
            "description": "Post key, together with `post_no`.",
            "example": "example_gallery"
          },
          "post_no": {
            "type": "integer",
            "minimum": 1,
            "description": "Post number. Unique only within a gallery, so a post is always keyed by `gall_id` and `post_no` together.",
            "example": 31900001
          },
          "snapshot_at": {
            "type": "string",
            "format": "date-time",
            "description": "Time the admin candidate-score table was read, at or before cycle_time. Delivered field: candidate_score_ts.snapshot_ts.",
            "example": "2026-09-29T02:05:00+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "recommend_up_cnt": {
            "type": "integer",
            "minimum": 0,
            "description": "Over-baseline recommendations, PC part. Proposed source mapping; confirm feature aggregation with the delivered model.",
            "example": 3,
            "nullable": true
          },
          "recommend_up_cnt_m": {
            "type": "integer",
            "minimum": 0,
            "description": "Over-baseline recommendations, mobile part. Proposed source mapping; confirm feature aggregation with the delivered model.",
            "example": 2,
            "nullable": true
          },
          "recommend_up_cnt_a": {
            "type": "integer",
            "minimum": 0,
            "description": "Over-baseline recommendations, app part. Proposed source mapping; confirm feature aggregation with the delivered model.",
            "example": 1,
            "nullable": true
          },
          "comment_cnt": {
            "type": "integer",
            "minimum": 0,
            "description": "Over-baseline comments, PC part. Proposed source mapping; confirm feature aggregation with the delivered model.",
            "example": 2,
            "nullable": true
          },
          "comment_cnt_m": {
            "type": "integer",
            "minimum": 0,
            "description": "Over-baseline comments, mobile part. Proposed source mapping; confirm feature aggregation with the delivered model.",
            "example": 1,
            "nullable": true
          },
          "comment_cnt_a": {
            "type": "integer",
            "minimum": 0,
            "description": "Over-baseline comments, app part. Proposed source mapping; confirm feature aggregation with the delivered model.",
            "example": 1,
            "nullable": true
          },
          "hit_cnt": {
            "type": "integer",
            "minimum": 0,
            "description": "Over-baseline views, PC part. Proposed source mapping; confirm feature aggregation with the delivered model.",
            "example": 5,
            "nullable": true
          },
          "hit_cnt_m": {
            "type": "integer",
            "minimum": 0,
            "description": "Over-baseline views, mobile part. Proposed source mapping; confirm feature aggregation with the delivered model.",
            "example": 4,
            "nullable": true
          },
          "hit_cnt_a": {
            "type": "integer",
            "minimum": 0,
            "description": "Over-baseline views, app part. Proposed source mapping; confirm feature aggregation with the delivered model.",
            "example": 1,
            "nullable": true
          },
          "dcbest_cnt": {
            "type": "integer",
            "minimum": 0,
            "description": "Send-to-Best votes, PC part. Proposed source mapping; confirm feature aggregation with the delivered model.",
            "example": 0,
            "nullable": true
          },
          "dcbest_cnt_m": {
            "type": "integer",
            "minimum": 0,
            "description": "Send-to-Best votes, mobile part. Proposed source mapping; confirm feature aggregation with the delivered model.",
            "example": 0,
            "nullable": true
          },
          "dcbest_cnt_a": {
            "type": "integer",
            "minimum": 0,
            "description": "Send-to-Best votes, app part. Proposed source mapping; confirm feature aggregation with the delivered model.",
            "example": 0,
            "nullable": true
          },
          "memo_size": {
            "type": "integer",
            "minimum": 0,
            "description": "Body size as the admin table counts it. A model input as delivered, so there is nothing to compute on DCinside's side.",
            "example": 1840,
            "nullable": true
          },
          "upimg_cnt": {
            "type": "integer",
            "minimum": 0,
            "description": "Admin snapshot image count. Latest as-of value takes precedence over candidate images_in_post, including explicit zero. Missing/null falls back to candidate metadata without waiting for uploads.",
            "example": 2,
            "nullable": true
          },
          "upimg_height": {
            "type": "integer",
            "minimum": 0,
            "maximum": 32767,
            "nullable": true,
            "example": 2410,
            "description": "Total uploaded-image height as delivered, capped at 32,767 in the source export. This is a source-field convention, not a queue or image-count limit."
          }
        },
        "required": [
          "gall_id",
          "post_no",
          "snapshot_at"
        ],
        "description": "Optional available admin-snapshot data carried in the proposed candidate_scores array. Verify the required features against Model 0924; the array may be empty."
      },
      "Removal": {
        "type": "object",
        "properties": {
          "gall_id": {
            "type": "string",
            "minLength": 1,
            "description": "Post key, together with `post_no`.",
            "example": "example_gallery"
          },
          "post_no": {
            "type": "integer",
            "minimum": 1,
            "description": "Post number. Unique only within a gallery, so a post is always keyed by `gall_id` and `post_no` together.",
            "example": 31900001
          },
          "reason": {
            "type": "string",
            "enum": [
              "saved",
              "hidden",
              "deleted",
              "aged_out"
            ],
            "example": "hidden",
            "description": "Received removals take effect before scoring and cannot be undone by resending candidates. Under the operator-independent sender policy, only deleted/aged_out are emitted; saved/hidden labels do not end pending retention. Saved/hidden remain accepted for compatibility and still remove the post; saved requires tier. The client applies the 24-hour age filter; there is no automatic server expiry."
          },
          "tier": {
            "type": "string",
            "enum": [
              "main",
              "light",
              "night",
              "app"
            ],
            "example": "main",
            "description": "Board tier; required for saved removals as confirmed in the September 30 client reply."
          },
          "decided_at": {
            "type": "string",
            "format": "date-time",
            "description": "Original decision time, preserved when delivery is delayed. Requires an explicit offset and must not be after cycle_time; no recency requirement.",
            "example": "2026-09-29T02:01:10+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "reason_code": {
            "type": "string",
            "example": "quota_full",
            "description": "Optional operator reason code. The code list and delivery mapping remain to be agreed; do not infer a fixed enumeration from this example."
          }
        },
        "required": [
          "gall_id",
          "post_no",
          "reason",
          "decided_at"
        ],
        "description": "A post leaving the pending pool."
      },
      "CycleRequest": {
        "type": "object",
        "properties": {
          "cycle_id": {
            "type": "string",
            "pattern": "^\\d{8}T\\d{4}$",
            "example": "20260929T0205",
            "description": "Idempotency key: the cycle time in KST as `YYYYMMDDTHHMM`, for example `20260929T0205`. The same id with the same body returns the stored response. The same id with a different body returns 409."
          },
          "cycle_time": {
            "type": "string",
            "format": "date-time",
            "description": "Scheduled five-minute rehearsal boundary with an explicit RFC 3339 offset; normalize to KST for cycle_id. Up to 60 seconds ahead of server time is allowed, inclusive. Runtime supports other configured intervals outside this rehearsal.",
            "example": "2026-09-29T02:05:00+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "candidates": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Candidate"
            },
            "description": "New posts since acknowledged delivery; re-include after uncertain timeout when GET finds no completed response. Identity gall_id/post_no is stored and counted once across repeated rows/cycles. Original content and known extracted_at remain unchanged. No candidate-count cap; the whole JSON body must fit within 16 MiB after decompression. Pending candidates remain until removed."
          },
          "engagement": {
            "type": "array",
            "maxItems": 5000,
            "items": {
              "$ref": "#/components/schemas/EngagementRow"
            },
            "description": "One row for every pending candidate, every cycle. Pending means sent in this or an earlier call and not yet in `removals`. The current 5,000-row cap is an engineering protection, not an agreed business quota; validate it against full-queue delivery."
          },
          "candidate_scores": {
            "type": "array",
            "maxItems": 5000,
            "items": {
              "$ref": "#/components/schemas/CandidateScoreRow"
            },
            "description": "Available admin snapshots, when present. Existing wire shape requires the array but permits []. Confirm feature mapping and requiredness with the client and delivered model. The current 5,000-row cap is an engineering protection, not an agreed business quota; validate it against full-queue delivery."
          },
          "removals": {
            "type": "array",
            "maxItems": 5000,
            "items": {
              "$ref": "#/components/schemas/Removal"
            },
            "description": "Saved, hidden, deleted or aged-out post identities. Re-inclusion within/across cycles is idempotent and counts only first accepted removals; candidate retransmission cannot revive a removed post."
          },
          "published_windows": {
            "default": null,
            "description": "Optional independent current last20 Main/Light published snapshots. Required for published-window dedup coverage; omission/null stays unknown, while [] asserts an empty feed. References reuse archived evidence. For never-delivered posts, bootstrap title/body/image metadata once here, with JPEGs uploaded separately; do not add them as pending candidates.",
            "allOf": [
              {
                "$ref": "#/components/schemas/PublishedWindows"
              }
            ],
            "nullable": true
          }
        },
        "required": [
          "cycle_id",
          "cycle_time",
          "candidates",
          "engagement",
          "candidate_scores",
          "removals"
        ],
        "description": "Proposed rehearsal wire shape. Required arrays and extra metadata need payload review. Current other-array and request-size protections are engineering limits, not client quotas."
      },
      "ResultRow": {
        "type": "object",
        "properties": {
          "gall_id": {
            "type": "string",
            "minLength": 1,
            "description": "Post key, together with `post_no`.",
            "example": "example_gallery"
          },
          "post_no": {
            "type": "integer",
            "minimum": 1,
            "description": "Post number. Unique only within a gallery, so a post is always keyed by `gall_id` and `post_no` together.",
            "example": 31900001
          },
          "status": {
            "type": "string",
            "enum": [
              "scored",
              "waiting",
              "not_scored"
            ],
            "example": "scored",
            "description": "scored: eligible after queue entry plus 15 minutes and a usable score is present. waiting: before that mark; due_cycle is the first eligible boundary. not_scored: inspect reason, including missing_clock for unknown queue entry."
          },
          "score": {
            "type": "number",
            "nullable": true,
            "example": 0.62,
            "description": "Final score from the verified delivered Model 0924: p_views minus the configured non-negative coefficient times downvote_share. Initially the coefficient is zero. Scores can be negative; do not clip them. Null unless scored."
          },
          "p_views": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "nullable": true,
            "example": 0.62,
            "description": "Optional views-head prediction from the real model; the intended target is top-quarter board views within the delivered tier/day/time grouping. Omit or use null when unavailable; not required for client-side final-score computation."
          },
          "p_comments": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "nullable": true,
            "example": null,
            "description": "Legacy optional head, unavailable in the delivered Model 0924. The runtime returns null and does not fabricate a prediction.",
            "deprecated": true
          },
          "p_up": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "nullable": true,
            "example": null,
            "description": "Legacy optional head, unavailable in the delivered Model 0924. The runtime returns null and does not fabricate a prediction.",
            "deprecated": true
          },
          "downvote_share": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "nullable": true,
            "example": 0.08,
            "description": "Optional predicted down-vote share from the real model. The initial down-vote penalty weight is 0. Omit or use null when unavailable; not required for client-side final-score computation."
          },
          "scored_at_cycle": {
            "type": "string",
            "format": "date-time",
            "description": "Cycle that produced this row score. Under the proposed refresh policy it advances when the pending post is re-scored in a new cycle; replay of the same completed cycle preserves the original value.",
            "example": "2026-09-29T02:05:00+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "due_cycle": {
            "type": "string",
            "format": "date-time",
            "description": "For waiting results, the first scheduled cycle boundary at or after extracted_at plus 15 minutes. Null/absent for scored results.",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$",
            "nullable": true
          },
          "reason": {
            "type": "string",
            "enum": [
              "missing_title",
              "missing_clock",
              "post_unavailable",
              "unknown_post"
            ],
            "example": "post_unavailable",
            "description": "Present when not_scored. post_unavailable means latest engagement status is missing or nomap; unknown_post means engagement arrived without a candidate; missing_clock means extracted_at is unknown and the 15-minute eligibility point cannot be established. missing_title remains a legacy value; empty titles alone do not prevent scoring."
          }
        },
        "required": [
          "gall_id",
          "post_no",
          "status"
        ],
        "description": "The state of one pending post."
      },
      "Counts": {
        "type": "object",
        "properties": {
          "candidates": {
            "type": "integer",
            "minimum": 0,
            "example": 1,
            "description": "Candidate identities first accepted in this cycle, excluding repeat deliveries. Group unique entrants by original extracted_at KST date for daily reconciliation."
          },
          "engagement": {
            "type": "integer",
            "minimum": 0,
            "example": 2,
            "description": "Rows received in `engagement`."
          },
          "candidate_scores": {
            "type": "integer",
            "minimum": 0,
            "example": 1,
            "description": "Rows received in `candidate_scores`."
          },
          "removals": {
            "type": "integer",
            "minimum": 0,
            "example": 1,
            "description": "Removal identities first accepted in this cycle, excluding repeats. Counts receipt, not original decided_at events or client-side operator labels. Under the operator-independent sender policy, saved/hidden decisions do not produce removal receipts."
          },
          "scored_now": {
            "type": "integer",
            "minimum": 0,
            "example": 1,
            "description": "Scoring events in this cycle, including rescoring; not unique first scores or a first-score SLA denominator."
          },
          "waiting": {
            "type": "integer",
            "minimum": 0,
            "example": 1,
            "description": "Results with status `waiting`."
          },
          "not_scored": {
            "type": "integer",
            "minimum": 0,
            "example": 0,
            "description": "Results with status `not_scored`."
          },
          "results": {
            "type": "integer",
            "minimum": 0,
            "example": 2,
            "description": "Rows in results for this cycle. Under the proposed refresh policy this includes newly computed scores for pending posts; historical replay stays unchanged."
          }
        },
        "required": [
          "candidates",
          "engagement",
          "candidate_scores",
          "removals",
          "scored_now",
          "waiting",
          "not_scored",
          "results"
        ],
        "description": "Row counts for this cycle."
      },
      "CycleResponse": {
        "type": "object",
        "properties": {
          "cycle_id": {
            "type": "string",
            "pattern": "^\\d{8}T\\d{4}$",
            "example": "20260929T0205",
            "description": "Echoed from the request."
          },
          "cycle_time": {
            "type": "string",
            "format": "date-time",
            "description": "Echoed from the request.",
            "example": "2026-09-29T02:05:00+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "received_at": {
            "type": "string",
            "format": "date-time",
            "description": "First valid receipt of this cycle ID, preserved across failed inference retries and completed replays.",
            "example": "2026-09-29T02:05:03+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "scored_at": {
            "type": "string",
            "format": "date-time",
            "description": "Actual scoring completion time sampled after the full inference batch. Measure client receipt time separately.",
            "example": "2026-09-29T02:05:09+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "model_version": {
            "type": "string",
            "example": "model0924-downvote0",
            "description": "Version reported by the actual loaded Model 0924 adapter and configured down-vote coefficient. No environment-only label override or proxy scorer is used."
          },
          "weights_version": {
            "type": "string",
            "example": "w0924-downvote0",
            "description": "Weights that actually produced this cycle's scores. Verify that refreshed scores and their version match; do not relabel cached scores."
          },
          "counts": {
            "$ref": "#/components/schemas/Counts"
          },
          "missing_feature_share": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "example": 0.11,
            "description": "Fraction of missing request counter fields. This does not count missing values across the model feature set and is not a rehearsal acceptance metric."
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "example": [],
            "description": "Implementation warnings. Unknown fields are not guaranteed to be listed. An empty list does not prove model or data readiness."
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ResultRow"
            },
            "description": "One result per pending candidate under the proposed refresh policy. Scores may change across new cycles; replay of a completed cycle is unchanged. Removed posts are absent from subsequent cycles."
          },
          "deduplication": {
            "default": null,
            "description": "Persisted LLM same-event rankings and honest unchecked coverage for main/light. Every raw score remains in results. Omitted for legacy completed cycles. Partial/unavailable completed reports remain immutable on POST replay and GET.",
            "allOf": [
              {
                "$ref": "#/components/schemas/DeduplicationReport"
              }
            ],
            "nullable": true
          }
        },
        "required": [
          "cycle_id",
          "cycle_time",
          "received_at",
          "scored_at",
          "model_version",
          "weights_version",
          "counts",
          "missing_feature_share",
          "warnings",
          "results"
        ]
      },
      "PickedByOperator": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "operator"
            ],
            "example": "operator",
            "description": "The post was chosen by an operator."
          }
        },
        "required": [
          "type"
        ],
        "description": "Chosen by an operator."
      },
      "PickedByModel": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "model"
            ],
            "example": "model",
            "description": "The post was chosen from the model's ranking."
          },
          "model_version": {
            "type": "string",
            "example": "m0924-weight0",
            "description": "`model_version` of the scores the choice was made from."
          },
          "weights_version": {
            "type": "string",
            "example": "w-1",
            "description": "`weights_version` of those scores."
          },
          "cycle_id": {
            "type": "string",
            "pattern": "^\\d{8}T\\d{4}$",
            "example": "20260929T0240",
            "description": "The cycle whose response the choice was made from."
          }
        },
        "required": [
          "type",
          "model_version",
          "weights_version",
          "cycle_id"
        ],
        "description": "Chosen from the model's ranking."
      },
      "Reading": {
        "type": "object",
        "properties": {
          "age_h": {
            "type": "integer",
            "enum": [
              1,
              6,
              24,
              48
            ],
            "example": 1,
            "description": "Hours since placement: 1, 6, 24 or 48."
          },
          "observed_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the counters were read.",
            "example": "2026-09-29T03:40:00+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "views": {
            "type": "integer",
            "minimum": 0,
            "description": "Cumulative views on the board copy.",
            "example": 9120,
            "nullable": true
          },
          "up": {
            "type": "integer",
            "minimum": 0,
            "description": "Cumulative up-votes on the board copy.",
            "example": 66,
            "nullable": true
          },
          "down": {
            "type": "integer",
            "minimum": 0,
            "description": "Cumulative down-votes on the board copy.",
            "example": 8,
            "nullable": true
          },
          "comment_cnt": {
            "type": "integer",
            "minimum": 0,
            "description": "Cumulative comments on the board copy.",
            "example": 30,
            "nullable": true
          }
        },
        "required": [
          "age_h",
          "observed_at",
          "views",
          "up",
          "down",
          "comment_cnt"
        ],
        "description": "Board-copy counters sampled at 1, 6, 24 or 48 hours after placement. Placement baselines support net-growth calculations. These are sampling ages, not upload frequencies; outcome and training-label semantics need payload review."
      },
      "OutcomeRecord": {
        "type": "object",
        "properties": {
          "placement_id": {
            "type": "string",
            "minLength": 1,
            "example": "example-4390001",
            "description": "DCinside placement identifier (delivered selected_id). Current implementation replaces the stored record on the same ID; send a complete known snapshot until incremental merging is agreed and implemented."
          },
          "gall_id": {
            "type": "string",
            "minLength": 1,
            "description": "Post key, together with `post_no`.",
            "example": "example_gallery"
          },
          "post_no": {
            "type": "integer",
            "minimum": 1,
            "description": "Post number. Unique only within a gallery, so a post is always keyed by `gall_id` and `post_no` together.",
            "example": 31900001
          },
          "tier": {
            "type": "string",
            "enum": [
              "main",
              "light",
              "night",
              "app"
            ],
            "example": "main",
            "description": "The board the post was placed on. Delivered files: `rtb_head_tag`."
          },
          "placed_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the post went onto the board. Also fixes the day and time block used for outcome percentiles. Delivered files: `exposed_at`.",
            "example": "2026-09-29T02:40:00+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "picked_by": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/PickedByOperator"
              },
              {
                "$ref": "#/components/schemas/PickedByModel"
              }
            ],
            "description": "Who chose the post: `{\"type\": \"operator\"}`, or `{\"type\": \"model\", \"model_version\", \"weights_version\", \"cycle_id\"}`."
          },
          "score_at_placement": {
            "type": "number",
            "nullable": true,
            "example": 0.62,
            "description": "The publish score the post had when placed. Lets us compare model and operator picks on the same board."
          },
          "views_at_placement": {
            "type": "integer",
            "minimum": 0,
            "example": 1875,
            "description": "Exact, from the board's `origin_hit`. Net views are readings minus this. Delivered files: `rtb_start_counts.views_at_placement`."
          },
          "up_at_placement": {
            "type": "integer",
            "minimum": 0,
            "description": "Last gallery-side up-votes before placement.",
            "example": 40,
            "nullable": true
          },
          "down_at_placement": {
            "type": "integer",
            "minimum": 0,
            "description": "Last gallery-side down-votes before placement.",
            "example": 3,
            "nullable": true
          },
          "comment_at_placement": {
            "type": "integer",
            "minimum": 0,
            "description": "Last gallery-side comment count before placement.",
            "example": 21,
            "nullable": true
          },
          "readings": {
            "type": "array",
            "maxItems": 4,
            "items": {
              "$ref": "#/components/schemas/Reading"
            },
            "description": "Known readings at 1, 6, 24 and 48 hours after placement. Daily rehearsal delivery may include several ages. On current repeated-placement updates include all known readings, because the handler replaces rather than merges."
          },
          "hidden_after_exposure": {
            "type": "boolean",
            "nullable": true,
            "example": true,
            "description": "The label DCinside has today: the post was hidden after it was exposed. Send it now, and use `hidden_at` once timed hides exist."
          },
          "hidden_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the post was taken down. DCinside's admin does not record this today, so send `null` until timed hides are logged through the unpublish path.",
            "example": "2026-09-29T05:00:00+09:00",
            "nullable": true,
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "deleted_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the post was deleted, if it was. Delivered files: `deletion.deleted_at`.",
            "example": "2026-09-29T06:00:00+09:00",
            "nullable": true,
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "deleted_by": {
            "type": "string",
            "enum": [
              "author",
              "admin",
              "auto",
              null
            ],
            "nullable": true,
            "example": null,
            "description": "Who deleted it: `author`, `admin` or `auto`. Delivered files: `deletion.deleted_by`."
          },
          "deleted_relative": {
            "type": "string",
            "enum": [
              "before_decision",
              "after_decision",
              null
            ],
            "nullable": true,
            "example": null,
            "description": "Whether the deletion came before or after the operator's decision. Only `after_decision` is an outcome of the placement. Delivered files: `deletion.deleted_relative`."
          }
        },
        "required": [
          "placement_id",
          "gall_id",
          "post_no",
          "tier",
          "placed_at",
          "picked_by",
          "views_at_placement",
          "readings"
        ],
        "description": "One placement of a post on a board, with its outcome readings."
      },
      "OutcomesRequest": {
        "type": "object",
        "properties": {
          "outcomes": {
            "type": "array",
            "maxItems": 5000,
            "items": {
              "$ref": "#/components/schemas/OutcomeRecord"
            },
            "description": "One record per placement. Current 5,000-record protection is an engineering limit, not an agreed quota. Daily operator-placed outcomes support rehearsal."
          }
        },
        "required": [
          "outcomes"
        ]
      },
      "OutcomesResponse": {
        "type": "object",
        "properties": {
          "received": {
            "type": "integer",
            "minimum": 0,
            "example": 1,
            "description": "Records in the request."
          },
          "created": {
            "type": "integer",
            "minimum": 0,
            "example": 1,
            "description": "Records stored as new placements."
          },
          "updated": {
            "type": "integer",
            "minimum": 0,
            "example": 0,
            "description": "Records that matched an existing `placement_id` and updated it."
          }
        },
        "required": [
          "received",
          "created",
          "updated"
        ]
      },
      "WeightSet": {
        "type": "object",
        "properties": {
          "views": {
            "type": "number",
            "minimum": 0,
            "example": 1,
            "description": "Weight on `p_views`."
          },
          "comments": {
            "type": "number",
            "minimum": 0,
            "example": 0,
            "description": "Weight on `p_comments`."
          },
          "up": {
            "type": "number",
            "minimum": 0,
            "example": 0,
            "description": "Weight on `p_up`."
          },
          "downvote_share": {
            "type": "number",
            "minimum": 0,
            "example": 0,
            "description": "Weight on the predicted down-vote share. It is subtracted, so send it as a positive number."
          },
          "hide": {
            "type": "integer",
            "enum": [
              0
            ],
            "example": 0,
            "description": "Reserved for the later hide head. Must be 0 or left out."
          },
          "deletion": {
            "type": "integer",
            "enum": [
              0
            ],
            "example": 0,
            "description": "Reserved for the later deletion head. Must be 0 or left out."
          }
        },
        "required": [
          "views",
          "comments",
          "up",
          "downvote_share"
        ],
        "description": "Publish-score weights. At least one of `views`, `comments` and `up` must be above 0."
      },
      "WeightsRequest": {
        "type": "object",
        "properties": {
          "version": {
            "type": "string",
            "pattern": "^[A-Za-z0-9._-]{1,64}$",
            "example": "w-1",
            "description": "Proposed stable configuration version. Current engineering protection: letters, digits, dot, underscore or hyphen, up to 64 characters. Same version/body can be retried; different configuration under that version conflicts."
          },
          "weights": {
            "$ref": "#/components/schemas/WeightSet"
          },
          "note": {
            "type": "string",
            "maxLength": 500,
            "example": "first trial: views only",
            "description": "Optional annotation; current implementation protection is 500 characters, not a client-agreed business limit."
          }
        },
        "required": [
          "version",
          "weights"
        ]
      },
      "WeightsAck": {
        "type": "object",
        "properties": {
          "version": {
            "type": "string",
            "example": "w-1",
            "description": "The accepted version, scheduled for first_cycle_time; not necessarily active at receipt time."
          },
          "first_cycle_time": {
            "type": "string",
            "format": "date-time",
            "description": "The first cycle the weights apply to, which is the next one.",
            "example": "2026-09-29T02:10:00+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          }
        },
        "required": [
          "version",
          "first_cycle_time"
        ]
      },
      "WeightsRecord": {
        "type": "object",
        "properties": {
          "version": {
            "type": "string",
            "example": "w-1",
            "description": "Weights version."
          },
          "weights": {
            "$ref": "#/components/schemas/WeightSet"
          },
          "note": {
            "type": "string",
            "nullable": true,
            "example": "first trial: views only",
            "description": "The note sent with the weights, or `null`."
          },
          "set_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the weights were accepted.",
            "example": "2026-09-29T02:02:11+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "first_cycle_time": {
            "type": "string",
            "format": "date-time",
            "description": "The first cycle the weights applied to.",
            "example": "2026-09-29T02:05:00+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          }
        },
        "required": [
          "version",
          "weights",
          "set_at",
          "first_cycle_time"
        ]
      },
      "WeightsState": {
        "type": "object",
        "properties": {
          "current": {
            "$ref": "#/components/schemas/WeightsRecord"
          },
          "previous": {
            "allOf": [
              {
                "$ref": "#/components/schemas/WeightsRecord"
              }
            ],
            "nullable": true,
            "description": "The version before the current one, or `null` if there is none."
          }
        },
        "required": [
          "current",
          "previous"
        ]
      },
      "Health": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok",
              "degraded"
            ],
            "example": "ok",
            "description": "ok when the real model and storage are ready and production credentials/durable storage are configured; otherwise degraded. This is not notification-delivery evidence."
          },
          "time": {
            "type": "string",
            "format": "date-time",
            "description": "Our clock.",
            "example": "2026-09-29T02:05:30+09:00",
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          },
          "api_version": {
            "type": "string",
            "example": "1",
            "description": "API major version."
          },
          "model_version": {
            "type": "string",
            "example": "model0924-downvote0",
            "description": "Version of the configured model adapter; check status for successful artifact readiness."
          },
          "weights_version": {
            "type": "string",
            "example": "w0924-downvote0",
            "description": "Weights in force."
          },
          "last_cycle_id": {
            "type": "string",
            "nullable": true,
            "example": "20260929T0205",
            "description": "Cycle ID with the latest durable first valid receipt, or null before any valid request."
          },
          "last_cycle_received_at": {
            "type": "string",
            "format": "date-time",
            "description": "Durable first receipt time, including valid requests whose inference failed. Replays and conflicts do not advance this watermark; it survives process restarts on the durable volume.",
            "example": "2026-09-29T02:05:03+09:00",
            "nullable": true,
            "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$"
          }
        },
        "required": [
          "status",
          "time",
          "api_version",
          "model_version",
          "weights_version",
          "last_cycle_id",
          "last_cycle_received_at"
        ]
      },
      "ErrorField": {
        "type": "object",
        "properties": {
          "path": {
            "type": "string",
            "example": "candidates[0].created_at",
            "description": "Path of the failing field."
          },
          "problem": {
            "type": "string",
            "example": "timestamp must include an explicit offset",
            "description": "Description of the validation failure."
          }
        },
        "required": [
          "path",
          "problem"
        ]
      },
      "ErrorBody": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "example": "bad_request",
            "description": "Machine-readable code. See Errors."
          },
          "message": {
            "type": "string",
            "example": "2 fields failed validation",
            "description": "Human-readable summary."
          },
          "fields": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ErrorField"
            },
            "description": "Every failing field, when the error is about the body."
          }
        },
        "required": [
          "code",
          "message"
        ]
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "$ref": "#/components/schemas/ErrorBody"
          }
        },
        "required": [
          "error"
        ]
      },
      "PublishedPost": {
        "description": "Actual published post reference, with optional once-only evidence bootstrap.\n\nReferences reuse archived candidate content and uploaded images. For a post\nnever delivered before, supply title/body and image metadata here once;\nupload JPEG bytes through the existing image route, not this JSON object.\nMissing evidence remains unchecked; bootstrap does not create a candidate.",
        "properties": {
          "gall_id": {
            "title": "Gall Id",
            "type": "string"
          },
          "post_no": {
            "title": "Post No",
            "type": "integer"
          },
          "placed_at": {
            "description": "Actual publication time with an explicit offset, no later than cycle_time. A quiet feed may contain old posts; there is no age expiry.",
            "format": "date-time",
            "title": "Placed At",
            "type": "string"
          },
          "title": {
            "default": null,
            "description": "Once-only bootstrap when archived title is missing. Omit after delivery; existing candidate evidence is preserved.",
            "title": "Title",
            "type": "string",
            "nullable": true
          },
          "body_html": {
            "default": null,
            "description": "Once-only original body when archived content is missing. Image URLs in HTML are not fetched.",
            "title": "Body Html",
            "type": "string",
            "nullable": true
          },
          "images_in_post": {
            "default": null,
            "description": "Accurate original image count for bootstrap; metadata is not image evidence. Omission preserves archived metadata.",
            "title": "Images In Post",
            "minimum": 0,
            "type": "integer",
            "nullable": true
          },
          "image_positions": {
            "default": null,
            "description": "Original image positions for bootstrap. Upload JPEGs separately with PUT /v1/images/{gall_id}/{post_no}/{NN}.",
            "title": "Image Positions",
            "items": {
              "maximum": 999,
              "minimum": 0,
              "type": "integer"
            },
            "type": "array",
            "nullable": true
          }
        },
        "required": [
          "gall_id",
          "post_no",
          "placed_at"
        ],
        "title": "PublishedPost",
        "type": "object"
      },
      "PublishedWindows": {
        "description": "Independent complete current last-20 Main and Light published snapshots.\n\nThe same scored candidate pool is checked against each tier independently.\nNull/omission means unknown; [] means a known empty feed. References reuse\narchived evidence; optional content bootstrap is sent once, not each cycle.",
        "properties": {
          "main": {
            "default": null,
            "title": "Main",
            "items": {
              "$ref": "#/components/schemas/PublishedPost"
            },
            "maxItems": 20,
            "type": "array",
            "nullable": true
          },
          "light": {
            "default": null,
            "title": "Light",
            "items": {
              "$ref": "#/components/schemas/PublishedPost"
            },
            "maxItems": 20,
            "type": "array",
            "nullable": true
          }
        },
        "title": "PublishedWindows",
        "type": "object"
      },
      "DedupCoverage": {
        "additionalProperties": false,
        "properties": {
          "total_candidates": {
            "minimum": 0,
            "title": "Total Candidates",
            "type": "integer"
          },
          "comparisons_total": {
            "description": "Theoretical unique candidate-to-published semantic pairs across both tier windows. Shared pairs count once; same-post identities are excluded. A positive match may make other pairs unnecessary without counting them as checked. No candidate-to-candidate pairs.",
            "minimum": 0,
            "title": "Comparisons Total",
            "type": "integer"
          },
          "comparisons_checked": {
            "description": "Required pairs covered by validated same/different facts used in this frozen cycle report, including facts completed during its bounded wait. Progress after finalization is reflected only in a new cycle. Unknown or failed comparisons are not checked.",
            "minimum": 0,
            "title": "Comparisons Checked",
            "type": "integer"
          },
          "provider_calls": {
            "description": "Provider calls initiated by the foreground report path; zero with background processing. Worker calls may overlap the HTTP wait and are recorded separately. Historical completed responses retain their original values.",
            "minimum": 0,
            "title": "Provider Calls",
            "type": "integer"
          },
          "cache_hits": {
            "description": "Unique required same/different semantic pair verdicts reused from the durable pair cache in this frozen cycle report, not unknown verdicts or cumulative background progress. Historical reports from the previous algorithm counted cached batches instead.",
            "minimum": 0,
            "title": "Cache Hits",
            "type": "integer"
          },
          "elapsed_ms": {
            "description": "Wall time for this cycle's deduplication assessment, including its bounded background-progress wait when performed. Excludes preceding raw scoring; not provider inference alone or client round-trip time.",
            "minimum": 0,
            "title": "Elapsed Ms",
            "type": "number"
          }
        },
        "required": [
          "total_candidates",
          "comparisons_total",
          "comparisons_checked",
          "provider_calls",
          "cache_hits",
          "elapsed_ms"
        ],
        "title": "DedupCoverage",
        "type": "object"
      },
      "DeduplicationReport": {
        "additionalProperties": false,
        "description": "Frozen summary of independent Main and Light published-window assessments.\n\nNew cycles durably prepare scores and exact evidence, then wait within the\nremaining response budget for needed background work to settle before\nfreezing this report. Facts completed during that wait can appear in the\nsame cycle. Settled full-evidence unknowns remain unchecked until changed\nevidence or model/prompt version allows reevaluation. Transient retry\ncooldown is not settlement. Completed POST retries and GET never refresh\nthis report; unfinished prepared retries do not repeat ingestion or scoring.\n\nMissing published context cannot complete. Only confirmed duplicates are\nexcluded from ranked; unchecked candidates remain eligible without being\ncertified unique. Coverage and status still describe assessment. Historical\ncompleted responses remain immutable, including legacy candidate rankings.",
        "properties": {
          "status": {
            "enum": [
              "completed",
              "partial",
              "unavailable",
              "disabled"
            ],
            "title": "Status",
            "type": "string"
          },
          "model": {
            "title": "Model",
            "type": "string",
            "nullable": true
          },
          "prompt_version": {
            "title": "Prompt Version",
            "type": "string",
            "nullable": true
          },
          "tiers": {
            "$ref": "#/components/schemas/DeduplicationTiers"
          },
          "coverage": {
            "default": null,
            "allOf": [
              {
                "$ref": "#/components/schemas/DedupCoverage"
              }
            ],
            "nullable": true
          }
        },
        "required": [
          "status",
          "model",
          "prompt_version",
          "tiers"
        ],
        "title": "DeduplicationReport",
        "type": "object"
      },
      "DeduplicationTiers": {
        "additionalProperties": false,
        "properties": {
          "main": {
            "$ref": "#/components/schemas/TierDeduplication"
          },
          "light": {
            "$ref": "#/components/schemas/TierDeduplication"
          }
        },
        "required": [
          "main",
          "light"
        ],
        "title": "DeduplicationTiers",
        "type": "object"
      },
      "DuplicateDecision": {
        "additionalProperties": false,
        "properties": {
          "gall_id": {
            "title": "Gall Id",
            "type": "string"
          },
          "post_no": {
            "title": "Post No",
            "type": "integer"
          },
          "duplicate_of": {
            "description": "For new cycles, one confirmed matching post in this tier's supplied window, not an exhaustive match list. Historical responses may reference a higher-ranked candidate.",
            "allOf": [
              {
                "$ref": "#/components/schemas/PostReference"
              }
            ]
          },
          "reason": {
            "description": "New cycles use same_event_published only. same_event_candidate remains accepted for immutable historical responses from the previous algorithm.",
            "enum": [
              "same_event_candidate",
              "same_event_published"
            ],
            "title": "Reason",
            "type": "string"
          }
        },
        "required": [
          "gall_id",
          "post_no",
          "duplicate_of",
          "reason"
        ],
        "title": "DuplicateDecision",
        "type": "object"
      },
      "PostReference": {
        "additionalProperties": false,
        "properties": {
          "gall_id": {
            "title": "Gall Id",
            "type": "string"
          },
          "post_no": {
            "title": "Post No",
            "type": "integer"
          }
        },
        "required": [
          "gall_id",
          "post_no"
        ],
        "title": "PostReference",
        "type": "object"
      },
      "TierDeduplication": {
        "additionalProperties": false,
        "description": "Published-window assessment with confirmed-duplicate-only exclusion.\n\nFor newly finalized cycles, ranked and duplicates partition scored candidates.\nUnchecked is a diagnostic subset of ranked, not another exclusion list.\nEligibility does not certify uniqueness; unknown, unfinished and unavailable\nassessments remain eligible. Candidate-to-candidate checks are not performed.\nMain and Light eligibility are independent; raw score rows are unchanged.\nHistorical completed responses retain their original ranking policy.",
        "properties": {
          "status": {
            "enum": [
              "completed",
              "partial",
              "unavailable",
              "disabled"
            ],
            "title": "Status",
            "type": "string"
          },
          "context": {
            "description": "published_window means a snapshot was supplied. candidate_only is the legacy wire label for missing context; new cycles do not perform candidate-only deduplication. Missing context leaves scored candidates ranked and unchecked without certifying uniqueness.",
            "enum": [
              "published_window",
              "candidate_only"
            ],
            "title": "Context",
            "type": "string"
          },
          "ranked": {
            "description": "All scored candidates except confirmed duplicates in this tier, descending raw score then gall_id/post_no. Includes unchecked candidates even when assessment is partial, unavailable or disabled; eligibility is not proof of uniqueness. Unchecked is an overlapping diagnostic subset and must not be subtracted for selection. No candidate-to-candidate checks or selection quota. Historical completed responses retain their original checked-only rankings.",
            "items": {
              "$ref": "#/components/schemas/PostReference"
            },
            "title": "Ranked",
            "type": "array"
          },
          "duplicates": {
            "items": {
              "$ref": "#/components/schemas/DuplicateDecision"
            },
            "title": "Duplicates",
            "type": "array"
          },
          "unchecked": {
            "description": "Diagnostic subset of ranked whose required comparisons are unfinished, unknown or unavailable. These candidates remain eligible under confirmed-duplicate-only exclusion, but are not certified unique. Do not exclude them solely for this status. Historical completed responses retain their original disjoint lists.",
            "items": {
              "$ref": "#/components/schemas/PostReference"
            },
            "title": "Unchecked",
            "type": "array"
          },
          "warnings": {
            "description": "Includes published_window_missing for absent context, published_content_missing for a supplied post with no delivered text or image evidence, and background_pending for incomplete assessment. Retained full-evidence unknowns stay unchecked without another call until their evidence/model/prompt changes. Bootstrap content can be supplied on a new cycle; worker provider failures are separate operational diagnostics.",
            "items": {
              "type": "string"
            },
            "title": "Warnings",
            "type": "array"
          }
        },
        "required": [
          "status",
          "context",
          "ranked",
          "duplicates",
          "unchecked",
          "warnings"
        ],
        "title": "TierDeduplication",
        "type": "object"
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Malformed JSON or a request-schema failure. Semantic checks may return only the first error; no exhaustive-error guarantee.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "bad_request",
                "message": "2 fields failed validation",
                "fields": [
                  {
                    "path": "candidates[0].created_at",
                    "problem": "timestamp must include an explicit offset"
                  },
                  {
                    "path": "engagement[3].views",
                    "problem": "must be a non-negative integer or null"
                  }
                ]
              }
            }
          }
        }
      },
      "Unauthorized": {
        "description": "The API key is missing or unknown.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "unauthorized",
                "message": "API key missing or unknown"
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "No such record.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "not_found",
                "message": "no cycle 20260929T0205"
              }
            }
          }
        }
      },
      "Conflict": {
        "description": "The id was already used with a different body. The first body stays.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "cycle_conflict",
                "message": "cycle 20260929T0205 was already received with a different body"
              }
            }
          }
        }
      },
      "TooLarge": {
        "description": "Current implementation size protection: 16 MiB JSON after decompression or 5 MiB per image. These are engineering protections, not agreed client quotas.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "too_large",
                "message": "JSON body exceeds 16 MiB after decompression"
              }
            }
          }
        }
      },
      "UnsupportedMediaType": {
        "description": "The body is not a JPEG.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "unsupported_media_type",
                "message": "expected image/jpeg"
              }
            }
          }
        }
      },
      "Unprocessable": {
        "description": "The body is valid JSON and matches the schema, but the values are not acceptable. Nothing is stored.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "unprocessable",
                "message": "cycle_time cannot be more than 60 seconds in the future",
                "fields": [
                  {
                    "path": "cycle_time",
                    "problem": "must not exceed server time by more than 60 seconds"
                  }
                ]
              }
            }
          }
        }
      },
      "ServerError": {
        "description": "Server failure. Skip late or unusable scores for the decision cycle, preserve unacknowledged data and reconcile delivery. Alert delivery must be verified separately.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": {
                "code": "server_error",
                "message": "internal error"
              }
            }
          }
        }
      }
    }
  },
  "x-contract-status": "proposed-scoring-only-rehearsal",
  "x-rehearsal-decisions": {
    "cycle_interval": "Latest client rehearsal requirement: fixed five minutes. Runtime also supports 2–5-minute configuration; rehearsal retains five.",
    "pending_rescoring": "Every eligible pending candidate is rescored each cycle; complete raw scores remain even when deduplication is unavailable or partial. Images never gate raw scoring.",
    "response_deadline": "Client requirement: 60 seconds from sending including at most one retry; late recovery for reconciliation only. The default 25-second server budget covers preparation, raw scoring, bounded background-progress waiting and finalization, with time reserved to persist the response. It does not preempt arbitrary scoring or prove end-to-end SLA. Background work may continue after finalization.",
    "first_score": "Return waiting before original extracted_at plus 15 minutes. Unchanged target: at least 99% first scored within two cycles after that mark; this does not end pending retention. Saved/hidden decisions remain in the denominator; immediate deleted/aged_out removals before the mark retain the early-removal exclusion. Record actual switch time and cohort assignment. The proposed 2026-10-15 00:00 KST cycle 20261015T0000 is unconfirmed; the earlier first-score-or-25-minute hold was never activated. Do not silently reclassify historical measurements.",
    "outcomes": "Daily operator outcomes may be shared separately by agreement. The outcomes endpoint is Not in scope for trial; it is not a required trial call or a client-draft pass condition.",
    "readiness": "Model integration, 15-minute eligibility, pending refresh, score provenance and durable transactions are implemented in source; deployed verification, representative deadline measurements and actual alert delivery remain required.",
    "weight_management": "Client-facing weight-management endpoints are Not in scope for trial. Surf configures the delivered Model 0924 down-vote-weight-0 setting.",
    "deduplication": "Background candidate-to-published same-event checks against exact independent current last 20 Main/Light snapshots. New cycles prepare scores and exact evidence before a bounded wait for still-needed work to settle; no first-usable or top-N early exit. Facts completed during that wait can enter the same frozen response. Prioritize highest-scored eligible work with up to four rolling calls and at most 20 pairs per call; completed batches commit independently. Retain valid full-evidence unknowns until evidence/model/prompt changes; unknown or unavailable evidence stays unchecked, and transient cooldown is not settlement. A positive match can stop comparisons needed only for its own tier. Foreground provider_calls is zero because worker metrics count model calls, including those overlapping HTTP waiting. Completed POST retries/GET remain immutable; unfinished prepared retries resume without repeating scoring. No candidate-to-candidate work, topic caps, publisher or candidate quota; no whole-queue deadline guarantee. For newly finalized cycles, each tier ranks every scored candidate except confirmed duplicates. Unchecked candidates remain eligible, including unknown, unfinished, timed-out, unavailable and disabled assessments; unchecked is a diagnostic subset of ranked and must not be subtracted for selection. Status and counters still describe assessment, not eligibility or proof of uniqueness. Historical completed responses retain their original ranking policy.",
    "recovery": "Candidate/removal identities are idempotent within and across cycles; preserve known original extracted_at and removal tombstones. Preparation commits state and frozen scores/evidence before bounded waiting; interruption afterwards resumes that preparation without repeated ingestion or scoring. Final responses persist once and remain immutable. Count candidates once by queue-entry KST day and scoring events per cycle. Planned drills are excluded from delivery/response percentages. Under the operator-independent policy, saved/hidden labels stay client-side and do not end retention or generate removal receipts. Continue engagement until deleted/aged_out under the client 24-hour age filter; no automatic server expiry. Saved/hidden remain accepted for compatibility; any removal precedes scoring and cannot be undone by resending. Retrospective backfill requires separate agreement. Capture between-cycle entrants with source unknown when unavailable. Counts distinguish unique entrants, scoring events and removal receipt, not a first-score SLA calculator or operator-label count. Post-decision scores use cycle-time evidence. Hidden alone does not veto picks; complete current-board skipping remains client-owned.",
    "stage_one": "Main/Light outside weekday 09:30–18:30 KST and throughout weekends/public holidays; optional phased start at 02:00–07:00. Night excluded; operator-hour interleaving is Stage 2."
  }
}
