ctfy.sdk.admin_resources.authoring

admin.authored_challenges — the operator's review queue.

The other half of ctfy.sdk.resources.authoring: entrants upload, an operator rules. Every method here is admin-gated server-side.

⚠️ Reading a file is a first-class verb and not a convenience. An approval is a person saying they have looked at somebody else's code before it runs on a node, so a client that could approve without being able to read would make the verdict a formality. See ctfy.server.routes.admin_authoring.

   1"""``admin.authored_challenges`` — the operator's review queue.
   2
   3The other half of `ctfy.sdk.resources.authoring`: entrants upload, an
   4operator rules. Every method here is admin-gated server-side.
   5
   6⚠️ **Reading a file is a first-class verb and not a convenience.** An
   7approval is a person saying they have looked at somebody else's code
   8before it runs on a node, so a client that could approve without being
   9able to read would make the verdict a formality. See
  10``ctfy.server.routes.admin_authoring``.
  11"""
  12
  13from __future__ import annotations
  14
  15import base64
  16import builtins
  17import json
  18from collections.abc import Mapping
  19from typing import Literal
  20
  21from ctfy.core.enums import (
  22    AuthoredDestination,
  23    AuthoredFlowAction,
  24    AuthoredQueueOrder,
  25    AuthoredScoresChoice,
  26    AuthoredSmokeFilter,
  27    AuthoredVersionSwitchWhen,
  28)
  29from ctfy.core.precheck_rerun import RerunScope
  30from ctfy.core.resource_budget import BuildNetwork
  31from ctfy.core.state.models import ReviewStatus
  32from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
  33from ctfy.sdk.base import BaseHttpClient
  34from ctfy.server.models import (
  35    AdminAuthoredChallengeDetail,
  36    AdminAuthoredChallengeRow,
  37    AdminAuthoredCurationRow,
  38    AdminAuthoredRevisionRow,
  39    AdminAuthoredVersionRow,
  40    AdminMaliceInfo,
  41    AdminMaliceStanding,
  42    AdminModelReview,
  43    AdminModelReviewInfo,
  44    AdminProbeInfo,
  45    AdminTaskInfo,
  46    AuthoredAuditReport,
  47    AuthoredBuildCapability,
  48    AuthoredChallengeFileBody,
  49    AuthoredCompetitionRow,
  50    AuthoredFlow,
  51    AuthoredGraduationInfo,
  52    AuthoredHistory,
  53    AuthoredOperatorRevisionInfo,
  54    AuthoredOtherUploads,
  55    AuthoredPointsAtStake,
  56    AuthoredQueueCounts,
  57    AuthoredRevisionDiff,
  58    AuthoredSmokeLive,
  59    AuthoredSmokeLog,
  60    AuthoredSmokeLogSummary,
  61    AuthoredSmokeStepLines,
  62    AuthoredStrayRow,
  63    AuthoredStripResult,
  64    AuthoredVersionDiff,
  65    AuthoredVersionMessage,
  66    AuthoredVersionStorageInfo,
  67    AuthoredVersionSwitchInfo,
  68    AuthoredVersionSwitchPlan,
  69    BulkCurationResponse,
  70    PrecheckRerunInfo,
  71    PrecheckRunsInfo,
  72)
  73
  74
  75class AdminAuthoredChallengesResource:
  76    """List, read and rule on player-authored uploads."""
  77
  78    def __init__(self, http: BaseHttpClient) -> None:
  79        self._http = http
  80
  81    def list(
  82        self,
  83        *,
  84        competition_id: str | None = None,
  85        team_id: str | None = None,
  86        review_status: ReviewStatus | None = None,
  87        smoke: AuthoredSmokeFilter | None = None,
  88        revision: ReviewStatus | None = None,
  89        order: AuthoredQueueOrder | None = None,
  90        destination: AuthoredDestination | None = None,
  91        offset: int = 0,
  92        limit: int = 50,
  93    ) -> PagedList[AdminAuthoredChallengeRow]:
  94        """The queue, oldest first.
  95
  96        ``None`` on a filter means *every*; ``""`` is a value, so
  97        passing it asks for the standalone catalog rather than for
  98        everything.
  99
 100        ``smoke`` narrows by what the smoke run found: a verdict,
 101        ``not_run`` for none yet, or ``in_flight`` for a run queued or
 102        running (each row's ``smoke_in_flight`` says which). The total is
 103        counted over the same filter.
 104
 105        ``revision`` narrows to uploads whose proposed revision carries
 106        that verdict (plan P3.1): ``pending`` waits for a reviewer,
 107        ``approved`` for :meth:`apply_revision`.
 108
 109        ``order="reputation"`` (plan P3.5) puts first the uploads of
 110        authors whose published challenges players received best — each
 111        row's ``author_reputation`` — then every upload with no reading,
 112        oldest first; ``None`` leaves the server's order, oldest first. It
 113        is ordered before the page is cut, so ``offset`` pages through
 114        that order.
 115
 116        ``destination`` narrows to where the author sent the upload (plan
 117        P3.4): ``public`` or ``eval_only``, the evaluation pool.
 118        """
 119        params: dict[str, object] = {"offset": offset, "limit": limit}
 120        if competition_id is not None:
 121            params["competition_id"] = competition_id
 122        if team_id is not None:
 123            params["team_id"] = team_id
 124        if review_status is not None:
 125            params["review_status"] = review_status
 126        if smoke is not None:
 127            params["smoke"] = smoke
 128        if revision is not None:
 129            params["revision"] = revision
 130        if order is not None:
 131            params["order"] = order
 132        if destination is not None:
 133            params["destination"] = destination
 134        resp = self._http.request("GET", "/admin/authored-challenges", params=params)
 135        _raise_for_status(resp)
 136        return _extract_items(resp.json(), AdminAuthoredChallengeRow)
 137
 138    def queue_counts(self, *, competition_id: str | None = None) -> AuthoredQueueCounts:
 139        """How many uploads each review tab holds and how long the longest
 140        has waited — one request, counted server-side (#2037 B1)."""
 141        params: dict[str, object] = {}
 142        if competition_id is not None:
 143            params["competition_id"] = competition_id
 144        resp = self._http.request("GET", "/admin/authored-challenges/queue-counts", params=params)
 145        _raise_for_status(resp)
 146        return AuthoredQueueCounts.model_validate(resp.json())
 147
 148    def build_capability(self) -> AuthoredBuildCapability:
 149        """Whether an approval on this deployment can become a live challenge.
 150
 151        ⚠️ **Ask this before reading a verdict as a deployment.** An
 152        ``approved`` row is a person saying they read the code; it is not
 153        a challenge in front of anybody until this answers ``can_build``.
 154
 155        ⚠️ **This line used to end "and today it never does".** That was
 156        true until `authored_sync` landed and is not any more: with a
 157        node marked ``untrusted_builds`` the answer is now ``True``, and
 158        a caller that still reads it as a constant would skip the one
 159        check that distinguishes a deployment which can run an approved
 160        upload from one which cannot.
 161
 162        ⚠️ **It answers for the deployment, never for a tree.** Whether
 163        *this* challenge reached a node is `push`'s task and its
 164        per-node lines.
 165        """
 166        resp = self._http.request("GET", "/admin/authoring/build-capability")
 167        _raise_for_status(resp)
 168        return AuthoredBuildCapability.model_validate(resp.json())
 169
 170    def get(self, challenge_id: str) -> AdminAuthoredChallengeDetail:
 171        """One upload, with the paths and sizes of what is in it."""
 172        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}")
 173        _raise_for_status(resp)
 174        return AdminAuthoredChallengeDetail.model_validate(resp.json())
 175
 176    def flow(self, challenge_id: str) -> AuthoredFlow:
 177        """Where this upload is in its lifecycle, what a platform admin can do
 178        next, and every transition that got it here — names included (#2147)."""
 179        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/flow")
 180        _raise_for_status(resp)
 181        return AuthoredFlow.model_validate(resp.json())
 182
 183    def precheck_runs(self, challenge_id: str) -> PrecheckRunsInfo:
 184        """The AI pre-check's managed-eval runs of this upload's pending
 185        versions, newest first (#3189)."""
 186        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/precheck-runs")
 187        _raise_for_status(resp)
 188        return PrecheckRunsInfo.model_validate(resp.json())
 189
 190    def precheck_run_artifact(self, challenge_id: str, run_id: str) -> bytes:
 191        """What one pre-check run's agent left behind, as ``.tar.gz`` bytes
 192        (#3260). Agent-written content: read it, never serve it."""
 193        resp = self._http.request(
 194            "GET", f"/admin/authored-challenges/{challenge_id}/precheck-runs/{run_id}/artifact"
 195        )
 196        _raise_for_status(resp)
 197        return bytes(resp.content)
 198
 199    def history(self, challenge_id: str) -> AuthoredHistory:
 200        """Everything that happened to this upload, oldest first, as one
 201        stream (#3093): the transition log, every AI pre-check and every
 202        injection review. Organisers and reviewers only."""
 203        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/history")
 204        _raise_for_status(resp)
 205        return AuthoredHistory.model_validate(resp.json())
 206
 207    def precheck_reruns(self, competition_id: str) -> PrecheckRerunInfo:
 208        """The event's re-check card (#3266): the two scopes' counts and the
 209        newest batch. Organisers only."""
 210        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/precheck-reruns")
 211        _raise_for_status(resp)
 212        return PrecheckRerunInfo.model_validate(resp.json())
 213
 214    def start_precheck_reruns(
 215        self, competition_id: str, *, scope: RerunScope = "unprechecked"
 216    ) -> PrecheckRerunInfo:
 217        """Re-run the AI pre-check over the event's approved uploads in
 218        *scope* (#3266). Evidence only: nothing it finds unpublishes one."""
 219        resp = self._http.request(
 220            "POST",
 221            f"/admin/competitions/{competition_id}/precheck-reruns",
 222            json={"scope": scope},
 223        )
 224        _raise_for_status(resp)
 225        return PrecheckRerunInfo.model_validate(resp.json())
 226
 227    def stop_precheck_reruns(self, competition_id: str, rerun_id: str) -> PrecheckRerunInfo:
 228        """Stop a running re-check batch; what finished is kept (#3266)."""
 229        resp = self._http.request(
 230            "POST", f"/admin/competitions/{competition_id}/precheck-reruns/{rerun_id}/stop"
 231        )
 232        _raise_for_status(resp)
 233        return PrecheckRerunInfo.model_validate(resp.json())
 234
 235    def smoke_logs(self, challenge_id: str) -> builtins.list[AuthoredSmokeLogSummary]:
 236        """The smoke runs kept for this upload, newest first (#2024) — the
 237        same scrubbed steps its author reads. The whole set: retention, not
 238        a page, bounds it."""
 239        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/smoke-logs")
 240        _raise_for_status(resp)
 241        return [AuthoredSmokeLogSummary.model_validate(row) for row in resp.json()]
 242
 243    def versions(self, challenge_id: str) -> builtins.list[AdminAuthoredVersionRow]:
 244        """Every stored version of this upload, newest first (#2037) — the
 245        rows its author reads, plus who ruled on each. The whole set."""
 246        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/versions")
 247        _raise_for_status(resp)
 248        return [AdminAuthoredVersionRow.model_validate(row) for row in resp.json()]
 249
 250    def competitions(self, challenge_id: str) -> builtins.list[AuthoredCompetitionRow]:
 251        """Every competition an upload is curated into, with what players did
 252        there (#2037) — starts and solves, counted over the whole set."""
 253        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/competitions")
 254        _raise_for_status(resp)
 255        return [AuthoredCompetitionRow.model_validate(row) for row in resp.json()]
 256
 257    def version_diff(
 258        self, challenge_id: str, *, from_version: str, to_version: str
 259    ) -> AuthoredVersionDiff:
 260        """Two versions of this upload compared (#2037) — the author's diff."""
 261        resp = self._http.request(
 262            "GET",
 263            f"/admin/authored-challenges/{challenge_id}/versions/diff",
 264            params={"from_version": from_version, "to_version": to_version},
 265        )
 266        _raise_for_status(resp)
 267        return AuthoredVersionDiff.model_validate(resp.json())
 268
 269    def version_archive(self, challenge_id: str, version_id: str) -> bytes:
 270        """One version as ``.tar.gz`` bytes, execute bits included (#2037)."""
 271        resp = self._http.request(
 272            "GET", f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/archive"
 273        )
 274        _raise_for_status(resp)
 275        return bytes(resp.content)
 276
 277    def version_switches(self, challenge_id: str) -> AuthoredVersionSwitchPlan:
 278        """Every switch of this upload's live version, newest first, and the
 279        running competitions holding it (#2037)."""
 280        resp = self._http.request(
 281            "GET", f"/admin/authored-challenges/{challenge_id}/version-switches"
 282        )
 283        _raise_for_status(resp)
 284        return AuthoredVersionSwitchPlan.model_validate(resp.json())
 285
 286    def switch_version(
 287        self,
 288        challenge_id: str,
 289        version_id: str,
 290        *,
 291        when: AuthoredVersionSwitchWhen = "now",
 292        reason: str = "",
 293        exec_surface_reviewed: bool = False,
 294        force: bool = False,
 295        force_reason: str = "",
 296    ) -> AuthoredVersionSwitchInfo:
 297        """Make a version live (#2037): an approved proposal is applied, an
 298        earlier approved version rolled back to (``reason`` required). ``when``
 299        is ``now`` or ``after_competitions`` — applied automatically once no
 300        running competition holds the challenge. Refused
 301        ``authored_version_switch_refused`` with coded reasons.
 302
 303        ``force`` / ``force_reason`` push an immediate switch through a
 304        competition that locks its challenges' versions while in play
 305        (#2041): a platform administrator's act, and only with a reason."""
 306        resp = self._http.request(
 307            "POST",
 308            f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/switch",
 309            json={
 310                "when": when,
 311                "reason": reason,
 312                "exec_surface_reviewed": exec_surface_reviewed,
 313                "force": force,
 314                "force_reason": force_reason,
 315            },
 316        )
 317        _raise_for_status(resp)
 318        return AuthoredVersionSwitchInfo.model_validate(resp.json())
 319
 320    def cancel_switch(self, challenge_id: str, switch_id: str) -> AuthoredVersionSwitchInfo:
 321        """Cancel a switch still waiting for its competitions to end (#2037)."""
 322        resp = self._http.request(
 323            "POST",
 324            f"/admin/authored-challenges/{challenge_id}/version-switches/{switch_id}/cancel",
 325        )
 326        _raise_for_status(resp)
 327        return AuthoredVersionSwitchInfo.model_validate(resp.json())
 328
 329    def version_messages(
 330        self, challenge_id: str, version_id: str
 331    ) -> builtins.list[AuthoredVersionMessage]:
 332        """One version's thread between its authors and reviewers (#2037)."""
 333        resp = self._http.request(
 334            "GET", f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/messages"
 335        )
 336        _raise_for_status(resp)
 337        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
 338
 339    def post_version_message(
 340        self, challenge_id: str, version_id: str, body: str, *, as_review_group: bool = True
 341    ) -> builtins.list[AuthoredVersionMessage]:
 342        """Write to an upload's authors on one version's thread (#2037) — as
 343        the review group unless ``as_review_group`` is false. The thread."""
 344        resp = self._http.request(
 345            "POST",
 346            f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/messages",
 347            json={"body": body, "as_review_group": as_review_group},
 348        )
 349        _raise_for_status(resp)
 350        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
 351
 352    def version_storage(self) -> AuthoredVersionStorageInfo:
 353        """What the version store holds (#2037): ``stored_bytes`` each
 354        distinct file once, ``logical_bytes`` every version stored whole."""
 355        resp = self._http.request("GET", "/admin/authoring/version-storage")
 356        _raise_for_status(resp)
 357        return AuthoredVersionStorageInfo.model_validate(resp.json())
 358
 359    def smoke_log(self, challenge_id: str, log_id: str) -> AuthoredSmokeLog:
 360        """One smoke run of this upload, step by step (#2024)."""
 361        resp = self._http.request(
 362            "GET", f"/admin/authored-challenges/{challenge_id}/smoke-logs/{log_id}"
 363        )
 364        _raise_for_status(resp)
 365        return AuthoredSmokeLog.model_validate(resp.json())
 366
 367    def smoke_live(self, challenge_id: str) -> AuthoredSmokeLive:
 368        """The smoke run of this upload that is queued or running now (#2145)."""
 369        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/smoke-live")
 370        _raise_for_status(resp)
 371        return AuthoredSmokeLive.model_validate(resp.json())
 372
 373    def cancel_smoke(self, challenge_id: str) -> AuthoredSmokeLive:
 374        """Take back this upload's smoke run while it still waits (#2145)."""
 375        resp = self._http.request(
 376            "POST", f"/admin/authored-challenges/{challenge_id}/smoke-live/cancel"
 377        )
 378        _raise_for_status(resp)
 379        return AuthoredSmokeLive.model_validate(resp.json())
 380
 381    def smoke_run_lines(
 382        self,
 383        challenge_id: str,
 384        run_id: str,
 385        key: str,
 386        *,
 387        before: int = 0,
 388        after: int = 0,
 389        limit: int = 200,
 390    ) -> AuthoredSmokeStepLines:
 391        """A window of one step's full output (#2145) — the same scrubbed
 392        lines its author reads; cursors as ``client.authoring.smoke_run_lines``."""
 393        resp = self._http.request(
 394            "GET",
 395            f"/admin/authored-challenges/{challenge_id}/smoke-runs/{run_id}/lines",
 396            params={"step": key, "before": before, "after": after, "limit": limit},
 397        )
 398        _raise_for_status(resp)
 399        return AuthoredSmokeStepLines.model_validate(resp.json())
 400
 401    def audit(self, challenge_id: str) -> AuthoredAuditReport:
 402        """Run the challenge audit over this upload and report it.
 403
 404        ⚠️ **Evidence, never a gate.** :meth:`review` does not consult
 405        this: a rule that refused an approval on a finding would make
 406        the operator's judgement the audit's to overrule, and the audit
 407        cannot see inside a `Dockerfile`'s `RUN` — which is the half a
 408        person is reading the files for.
 409
 410        ⚠️ Read ``outcome`` before the counts. An empty ``findings``
 411        list means *nothing was found* only when it says ``ok``;
 412        ``timed_out`` and ``failed`` are the audit declining to answer,
 413        and reading either as clean is the whole reason the field
 414        exists.
 415        """
 416        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/audit")
 417        _raise_for_status(resp)
 418        return AuthoredAuditReport.model_validate(resp.json())
 419
 420    def model_review(self, challenge_id: str) -> AdminModelReviewInfo:
 421        """The model's reading of this upload and of its proposed revision
 422        (#1965 D): the injection verdict with its full reasoning and the
 423        spans it quoted, the advisory findings, and near copies.
 424
 425        ⚠️ Read ``effective_injection`` to know what the platform acts on —
 426        ``injection`` is what the model said, and an override makes it
 427        ``clean``. ``unknown`` is the platform's problem (``problem`` says
 428        which) and never blocks.
 429        """
 430        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/model-review")
 431        _raise_for_status(resp)
 432        return AdminModelReviewInfo.model_validate(resp.json())
 433
 434    def run_model_review(self, challenge_id: str, *, revision_id: str = "") -> AdminTaskInfo:
 435        """Ask the model to read this upload, or its proposed revision —
 436        advice: the press decides nothing. 409 while one is in flight."""
 437        params = {"revision_id": revision_id} if revision_id else None
 438        resp = self._http.request(
 439            "POST", f"/admin/authored-challenges/{challenge_id}/model-review", params=params
 440        )
 441        _raise_for_status(resp)
 442        return AdminTaskInfo.model_validate(resp.json())
 443
 444    def assistant_review(self, challenge_id: str) -> AdminProbeInfo:
 445        """The reviewer assistant's newest run on this upload's live tree and
 446        on its proposed revision (#2830) — advice for the person reviewing,
 447        never a verdict. ``enabled`` is whether this deployment names a
 448        model at all."""
 449        resp = self._http.request(
 450            "GET", f"/admin/authored-challenges/{challenge_id}/assistant-review"
 451        )
 452        _raise_for_status(resp)
 453        return AdminProbeInfo.model_validate(resp.json())
 454
 455    def rerun_assistant_review(self, challenge_id: str) -> AdminTaskInfo:
 456        """Smoke this upload again with the AI pre-check, even though this
 457        version was probed already (#2830, #3053). A rerun *is* a smoke, so it
 458        takes :meth:`smoke`'s door (super-admin); ``409 authoring_probe_off``
 459        while the event chose no model, ``authoring_probe_not_isolated`` with
 460        no quarantine or dedicated node, ``authoring_probe_budget_spent``
 461        once the version's cap is spent."""
 462        resp = self._http.request(
 463            "POST", f"/admin/authored-challenges/{challenge_id}/assistant-review"
 464        )
 465        _raise_for_status(resp)
 466        return AdminTaskInfo.model_validate(resp.json())
 467
 468    def override_model_review(
 469        self, challenge_id: str, review_id: str, *, note: str
 470    ) -> AdminModelReview:
 471        """Say the text a review flagged is not an injection. ``note`` is
 472        required — the audit's *why*. An upload or revision the verdict
 473        rejected under automatic review is decided again."""
 474        resp = self._http.request(
 475            "POST",
 476            f"/admin/authored-challenges/{challenge_id}/model-review/{review_id}/override",
 477            json={"note": note},
 478        )
 479        _raise_for_status(resp)
 480        return AdminModelReview.model_validate(resp.json())
 481
 482    def malice_review(self, challenge_id: str) -> AdminMaliceInfo:
 483        """The malice check of this upload's live tree and of its proposed
 484        revision (#3211): ``hold``, every reason, the rule hits, the model's
 485        reading and quotes, and any ruling. Never calls a model."""
 486        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/malice-review")
 487        _raise_for_status(resp)
 488        return AdminMaliceInfo.model_validate(resp.json())
 489
 490    def override_malice_review(
 491        self,
 492        challenge_id: str,
 493        review_id: str,
 494        *,
 495        decision: Literal["clear", "confirm"],
 496        note: str,
 497    ) -> AdminMaliceStanding:
 498        """Rule on a malice hold: ``clear`` releases it (a version the
 499        automatic chain held is decided again), ``confirm`` keeps it.
 500        ``note`` is required — the audit's *why*."""
 501        resp = self._http.request(
 502            "POST",
 503            f"/admin/authored-challenges/{challenge_id}/malice-review/{review_id}/override",
 504            json={"decision": decision, "note": note},
 505        )
 506        _raise_for_status(resp)
 507        return AdminMaliceStanding.model_validate(resp.json())
 508
 509    def read_file(self, challenge_id: str, path: str) -> AuthoredChallengeFileBody:
 510        """One file's contents.
 511
 512        ⚠️ The body is **attacker-authored text written to be run**.
 513        ``binary`` and ``truncated`` say what you are not looking at;
 514        treat a ``False`` on either as the only claim that you are.
 515        """
 516        # ⚠️ The path rides the body, never the URL (#1929): a WAF that
 517        # refuses a URL ending in ``.ini`` does not read a JSON body.
 518        resp = self._http.request(
 519            "POST", f"/admin/authored-challenges/{challenge_id}/file", json={"path": path}
 520        )
 521        _raise_for_status(resp)
 522        return AuthoredChallengeFileBody.model_validate(resp.json())
 523
 524    def operator_revise(
 525        self,
 526        challenge_id: str,
 527        *,
 528        archive: bytes | None = None,
 529        put: Mapping[str, bytes] | None = None,
 530        delete: builtins.list[str] | None = None,
 531        note: str = "",
 532        approve: bool = False,
 533        replace_waiting: bool = False,
 534    ) -> AuthoredOperatorRevisionInfo:
 535        """Submit a new version of somebody else's upload on their behalf (#3002).
 536
 537        Exactly one way in: ``archive`` (a whole ``.tar.gz``), or edits on top
 538        of the current tree — ``put`` maps a path to its new bytes, ``delete``
 539        names paths to remove. The tree meets every rule an upload's does;
 540        the author stays the author, the version records you as
 541        ``revised_by``, and the uploader is told which files changed.
 542        ``approve`` approves it in the same press, under your name.
 543        ``replace_waiting`` confirms replacing a revision the author already
 544        has waiting (otherwise ``409 authored_operator_revision_replaces_proposal``).
 545        """
 546        data: dict[str, str] = {
 547            "note": note,
 548            "approve": "true" if approve else "",
 549            "replace_waiting": "true" if replace_waiting else "",
 550        }
 551        files: dict[str, tuple[str, bytes, str]] = {}
 552        if archive is not None:
 553            files["file"] = ("revision.tar.gz", archive, "application/gzip")
 554        else:
 555            edits = [
 556                {"path": path, "content_b64": base64.b64encode(body).decode()}
 557                for path, body in (put or {}).items()
 558            ] + [{"path": path, "content_b64": None} for path in (delete or [])]
 559            data["edits"] = json.dumps(edits)
 560        resp = self._http.request(
 561            "POST",
 562            f"/admin/authored-challenges/{challenge_id}/operator-revisions",
 563            data=data,
 564            files=files or None,
 565        )
 566        _raise_for_status(resp)
 567        return AuthoredOperatorRevisionInfo.model_validate(resp.json())
 568
 569    def graduation(self, challenge_id: str, corpus_id: str = "") -> AuthoredGraduationInfo:
 570        """Whether an upload may graduate into the corpus, and as what (plan P3.3).
 571
 572        ``refusals`` names every reason it may not yet — empty when it may.
 573        ``corpus_id`` is the id it would take: the one passed, or else the
 574        next ``COMMUNITY-NNN`` this deployment's catalog does not hold. The
 575        pull request's title and body are what a person opens it with.
 576        """
 577        params = {"corpus_id": corpus_id} if corpus_id else None
 578        resp = self._http.request(
 579            "GET", f"/admin/authored-challenges/{challenge_id}/graduation", params=params
 580        )
 581        _raise_for_status(resp)
 582        return AuthoredGraduationInfo.model_validate(resp.json())
 583
 584    def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes:
 585        """The ``.tar.gz`` to unpack at the corpus repository's root.
 586
 587        It holds ``COMMUNITY/<corpus_id>/``: the tree as reviewed, the
 588        ``LICENSE`` its author declared and a ``CREDITS.md``. Nothing is
 589        recorded on the platform — opening the pull request is a person's
 590        act, in another repository.
 591        """
 592        resp = self._http.request(
 593            "GET",
 594            f"/admin/authored-challenges/{challenge_id}/graduation/bundle",
 595            params={"corpus_id": corpus_id},
 596        )
 597        _raise_for_status(resp)
 598        return resp.content
 599
 600    def review(
 601        self,
 602        challenge_id: str,
 603        *,
 604        status: ReviewStatus,
 605        note: str = "",
 606        exec_surface_reviewed: bool = False,
 607    ) -> AdminAuthoredChallengeRow:
 608        """Record a verdict. ``rejected`` requires a note.
 609
 610        ``pending`` withdraws a verdict and clears its trail — a
 611        mis-clicked approval is undoable without deleting the upload.
 612        ``exec_surface_reviewed`` (#3337) confirms, with an approval, that
 613        you read what the tree runs; with your verdict that is a person's
 614        release, which replaces an automatic review's policy release.
 615        """
 616        resp = self._http.request(
 617            "POST",
 618            f"/admin/authored-challenges/{challenge_id}/review",
 619            json={"status": status, "note": note},
 620            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
 621        )
 622        _raise_for_status(resp)
 623        return AdminAuthoredChallengeRow.model_validate(resp.json())
 624
 625    def push(self, challenge_id: str) -> AdminTaskInfo:
 626        """Send an approved upload to every node marked for untrusted builds.
 627
 628        ⚠️ **The verb after `publish`, and they are not the same act.**
 629        `publish` writes the tree into *this deployment's* catalog root;
 630        a multi-host node's catalog is a different directory on a
 631        different machine, and until this runs the node answers 404 for
 632        the id the platform now lists.
 633
 634        Returns the task: the push is per node, and which node refused
 635        is on its log rather than in one summary line.
 636        """
 637        resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/push")
 638        _raise_for_status(resp)
 639        return AdminTaskInfo.model_validate(resp.json())
 640
 641    def smoke(
 642        self, challenge_id: str, *, build_network: BuildNetwork | None = None
 643    ) -> AdminTaskInfo:
 644        """Run this upload's own reference exploit against it, on a node.
 645
 646        ⚠️ **The machine check an approval cannot make.** The audit is
 647        static and a reviewer reads files; only a run says whether the
 648        estate comes up and the exploit captures what it claims.
 649
 650        Returns the task — the verdict is on its log, and it is
 651        deliberately four-valued: *solved*, *unsolved*, *estate_failed*,
 652        *malformed*, plus *errored* for a node that could not tell.
 653
 654        #2873: ``build_network`` (``online`` / ``offline``) is whether this
 655        run's build ``RUN`` steps reach the network; ``None`` is the upload's
 656        competition's setting.
 657        """
 658        resp = self._http.request(
 659            "POST",
 660            f"/admin/authored-challenges/{challenge_id}/smoke",
 661            params={"build_network": build_network} if build_network else None,
 662        )
 663        _raise_for_status(resp)
 664        return AdminTaskInfo.model_validate(resp.json())
 665
 666    def resmoke_platform_faults(
 667        self, *, competition_id: str = "", dry_run: bool = False
 668    ) -> AdminTaskInfo:
 669        """Re-smoke every upload #2029 mislabelled, once (the operator one-shot).
 670
 671        A dropped execute bit and a run-time ``pip install`` on a no-egress
 672        smoke network recorded the author's ``unsolved`` / ``estate_failed``;
 673        both are fixed. This enqueues the backfill, which selects those
 674        uploads, re-runs each uncharged, and mails each author once that the
 675        earlier failure was the platform's. ``dry_run`` reports the selection
 676        and runs nothing. Returns the task.
 677        """
 678        resp = self._http.request(
 679            "POST",
 680            "/admin/authored-challenges/resmoke-platform-faults",
 681            params={"competition_id": competition_id, "dry_run": dry_run},
 682        )
 683        _raise_for_status(resp)
 684        return AdminTaskInfo.model_validate(resp.json())
 685
 686    def strays(self) -> builtins.list[AuthoredStrayRow]:
 687        """Every stored challenge id a competition may not hold (#2553): an
 688        invitation-only event's upload in another event's list or map — what
 689        :meth:`strip_strays` removes. Read-only; empty is clean. (Before an
 690        upgrade the check is offline: ``ctfy-admin admin authored strays --db``.)"""
 691        resp = self._http.request("GET", "/admin/authored-challenges/strays")
 692        _raise_for_status(resp)
 693        return [AuthoredStrayRow.model_validate(row) for row in resp.json()]
 694
 695    def strip_strays(self, *, apply: bool = False) -> AuthoredStripResult:
 696        """Remove every stray (#2553) — a dry run unless ``apply``. Each
 697        removal is recorded with the platform as actor and keeps its solves'
 698        points; the boxes it left behind are reclaimed."""
 699        resp = self._http.request(
 700            "POST",
 701            "/admin/authored-challenges/strays/strip",
 702            params={"apply": "true" if apply else "false"},
 703        )
 704        _raise_for_status(resp)
 705        return AuthoredStripResult.model_validate(resp.json())
 706
 707    def publish(
 708        self, challenge_id: str, *, exec_surface_reviewed: bool = False
 709    ) -> AdminAuthoredChallengeRow:
 710        """Write an approved upload into the deployment's catalog.
 711
 712        A separate verb from :meth:`review` on purpose: approving is a
 713        person's verdict on somebody else's code, and it must not fail
 714        because a corpus commit landed on the id in the days since. The
 715        409 this raises carries every reason at once — the id is taken,
 716        the row is not approved, the tree has no ``metadata.yaml`` — so a
 717        caller fixes them in one pass rather than one press at a time.
 718
 719        ``exec_surface_reviewed`` confirms you read the files the tree
 720        runs on a node or hands players to run (``executes`` on
 721        :meth:`get`'s file list, plan P2.3). A tree with such files is
 722        refused without it — once; the stamp then records who read it.
 723
 724        The row that comes back is **read back from the store**, so its
 725        ``in_catalog`` is a fact about the disk after the write.
 726        """
 727        resp = self._http.request(
 728            "POST",
 729            f"/admin/authored-challenges/{challenge_id}/publish",
 730            # Only what was said: absent is the server's own default.
 731            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
 732        )
 733        _raise_for_status(resp)
 734        return AdminAuthoredChallengeRow.model_validate(resp.json())
 735
 736    def release_to_catalog(
 737        self, challenge_id: str, *, exec_surface_reviewed: bool = False
 738    ) -> AdminAuthoredChallengeRow:
 739        """Put a published upload in the **public catalog** (#1989).
 740
 741        ⚠️ **Publishing is not this.** A published upload is seen only in
 742        the competitions that curate it — by their players once each
 743        starts, their organisers, its authors and platform admins. This is
 744        the separate step that lists it to everybody; who released it and
 745        when lands on the row (``catalog_released_by`` /
 746        ``catalog_released_at``), and a second call changes nothing.
 747
 748        A tree an automatic review released is refused (409, with reasons)
 749        until a person has recorded a content verdict on it (:meth:`review`)
 750        and a platform admin has read what it runs — ``exec_surface_reviewed``
 751        confirms the second on this call.
 752        """
 753        resp = self._http.request(
 754            "POST",
 755            f"/admin/authored-challenges/{challenge_id}/catalog-release",
 756            # Only what was said: absent is the server's own default.
 757            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
 758        )
 759        _raise_for_status(resp)
 760        return AdminAuthoredChallengeRow.model_validate(resp.json())
 761
 762    def retire(
 763        self, challenge_id: str, *, scores: AuthoredScoresChoice | None = None
 764    ) -> AdminAuthoredChallengeRow:
 765        """Stop offering an upload; its history is kept (#2760).
 766
 767        Platform admins only. It leaves every competition and is no longer
 768        open to everyone; running environments run out on their own. A call
 769        on an upload already retired changes nothing.
 770
 771        ``scores`` (#2991) decides the points already scored on it: ``kept``
 772        (and ``None``, the server's default) or ``removed`` — see
 773        :meth:`points_at_stake` for what each would touch.
 774        """
 775        resp = self._http.request(
 776            "POST",
 777            f"/admin/authored-challenges/{challenge_id}/retire",
 778            params={"scores": scores} if scores else None,
 779        )
 780        _raise_for_status(resp)
 781        return AdminAuthoredChallengeRow.model_validate(resp.json())
 782
 783    def unretire(self, challenge_id: str) -> AdminAuthoredChallengeRow:
 784        """Offer a retired upload again (#2760): back to approved with no
 785        competition using it. A call on one that is not retired changes
 786        nothing."""
 787        resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/unretire")
 788        _raise_for_status(resp)
 789        return AdminAuthoredChallengeRow.model_validate(resp.json())
 790
 791    def other_uploads(self, challenge_id: str) -> AuthoredOtherUploads:
 792        """The other uploads of this upload's challenge (#3110): same
 793        competition, same title, same team or author — and the one approved
 794        and smoked clean, suggested as the one to keep."""
 795        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/other-uploads")
 796        _raise_for_status(resp)
 797        return AuthoredOtherUploads.model_validate(resp.json())
 798
 799    def merge(
 800        self, challenge_id: str, *, into: str, reason: str, confirm: bool = False
 801    ) -> AdminAuthoredChallengeDetail:
 802        """Fold an upload into another upload of the same challenge (#3110):
 803        recorded as ``merged`` with ``reason``, and taken out of every
 804        competition's set (points scored there kept). Platform admins only.
 805        Refused while anything is in play; ``confirm`` lifts only a running
 806        event that would lose the challenge."""
 807        params: dict[str, str] = {"into": into, "reason": reason}
 808        if confirm:
 809            params["confirm"] = "true"
 810        resp = self._http.request(
 811            "POST", f"/admin/authored-challenges/{challenge_id}/merge", params=params
 812        )
 813        _raise_for_status(resp)
 814        return AdminAuthoredChallengeDetail.model_validate(resp.json())
 815
 816    def unmerge(self, challenge_id: str, *, reason: str = "") -> AdminAuthoredChallengeDetail:
 817        """Undo a merge (#3110): the upload is its own challenge again; no
 818        competition comes back by itself."""
 819        resp = self._http.request(
 820            "POST",
 821            f"/admin/authored-challenges/{challenge_id}/unmerge",
 822            params={"reason": reason} if reason else None,
 823        )
 824        _raise_for_status(resp)
 825        return AdminAuthoredChallengeDetail.model_validate(resp.json())
 826
 827    def unrelease_from_catalog(self, challenge_id: str) -> AdminAuthoredChallengeRow:
 828        """Withdraw an upload's public-catalog release (#2759): no longer
 829        open to everyone, only the competitions using it.
 830
 831        Platform admins only. Every competition use is kept — an upload no
 832        competition uses is then seen by nobody — and environments running
 833        outside a competition run out on their own. Recorded as an
 834        ``unreleased`` lifecycle record; a call on an upload that is not
 835        open to everyone changes nothing.
 836        """
 837        resp = self._http.request(
 838            "POST", f"/admin/authored-challenges/{challenge_id}/catalog-unrelease"
 839        )
 840        _raise_for_status(resp)
 841        return AdminAuthoredChallengeRow.model_validate(resp.json())
 842
 843    def withdraw_policy_release(self, challenge_id: str) -> AdminAuthoredChallengeRow:
 844        """Withdraw the policy release an automatic review wrote (#3337):
 845        the tree is no longer held to its event's dedicated pool, and starts
 846        nowhere until a person approves it with ``exec_surface_reviewed``
 847        (:meth:`review`). Platform admins only; recorded as
 848        ``policy_unreleased``; a call with no release changes nothing.
 849        """
 850        resp = self._http.request(
 851            "POST", f"/admin/authored-challenges/{challenge_id}/policy-unrelease"
 852        )
 853        _raise_for_status(resp)
 854        return AdminAuthoredChallengeRow.model_validate(resp.json())
 855
 856    def points_at_stake(
 857        self, challenge_id: str, action: AuthoredFlowAction, competition_id: str = ""
 858    ) -> AuthoredPointsAtStake:
 859        """What removing an upload would decide about the points already
 860        scored on it (#2991): for ``uncurate`` (in ``competition_id``, or its
 861        own), ``retire`` or ``unpublish``, the ranked solves and teams at
 862        stake and the effects of keeping and of removing them."""
 863        params: dict[str, str] = {"action": action}
 864        if competition_id:
 865            params["competition_id"] = competition_id
 866        resp = self._http.request(
 867            "GET", f"/admin/authored-challenges/{challenge_id}/points", params=params
 868        )
 869        _raise_for_status(resp)
 870        return AuthoredPointsAtStake.model_validate(resp.json())
 871
 872    def unpublish(
 873        self,
 874        challenge_id: str,
 875        *,
 876        reason: str,
 877        scores: AuthoredScoresChoice | None = None,
 878    ) -> AdminAuthoredChallengeRow:
 879        """Take a published challenge back out of the deployment's catalog.
 880
 881        ``reason`` is required (``422`` with ``unpublish_needs_reason`` when
 882        blank): the take-down is recorded — who, when, why — on the upload's
 883        publication history (``AdminAuthoredChallengeDetail.lifecycle``,
 884        #1965 H), which the authoring board reads.
 885
 886        :meth:`publish` had no inverse, so an upload that turned out to
 887        leak its own answer stayed in front of every entrant and the only
 888        recourse was a shell on the platform host.
 889
 890        It deletes the *publication*, never the row: the author's tree,
 891        the reviewer's verdict and the smoke result all survive, and
 892        publishing again is the same call it always was. The boxes already
 893        running on it are stopped too (the route's ``stop_instances``
 894        defaults on and this call does not send it), so nobody new can
 895        launch and nobody keeps playing it. It also leaves every competition
 896        that uses it (#2740 D7); allowing it again adds it back to none.
 897
 898        Raises 409 when the deployment's catalog does not hold the id,
 899        which is the mirror of publishing something already published.
 900
 901        ``scores`` (#2991): ``removed`` takes the points already scored on it
 902        off the board; ``kept`` and ``None`` keep them.
 903        """
 904        body: dict[str, str] = {"reason": reason}
 905        if scores:
 906            body["scores"] = scores
 907        resp = self._http.request(
 908            "POST",
 909            f"/admin/authored-challenges/{challenge_id}/unpublish",
 910            json=body,
 911        )
 912        _raise_for_status(resp)
 913        return AdminAuthoredChallengeRow.model_validate(resp.json())
 914
 915    def curate(
 916        self, challenge_id: str, competition_id: str = "", *, reason: str = ""
 917    ) -> AdminAuthoredCurationRow:
 918        """Put a published upload in front of this competition's entrants.
 919
 920        ⚠️ **The step after `publish`, and the two are not one act.**
 921        Publishing writes the tree into this deployment's catalog;
 922        curating adds its id to *one competition's* challenge set. Until
 923        this existed the only way to take it was the competition edit
 924        form, which re-sends the whole list — so two organisers working a
 925        review queue dropped each other's additions. This appends.
 926
 927        Refused (409, with reasons) for a competition whose set comes
 928        from a catalog bucket, and for an id this deployment has not
 929        published: the second would put a challenge in front of entrants
 930        that no node can start.
 931
 932        ``competition_id`` names another competition to curate it into —
 933        the workshop → arena step — and every refusal is then asked of
 934        that one. Empty is the upload's own competition.
 935
 936        ``warnings`` on the answer names what the target would refuse at its
 937        own upload door and this verb does not (#1965): a static answer
 938        curated into a competition reviewed automatically.
 939
 940        ``reason`` confirms the change for a competition that locks its
 941        challenges while in play (#2041) — refused
 942        (``curation_locked_needs_reason``) without one, and recorded with it.
 943        """
 944        resp = self._http.request(
 945            "POST",
 946            f"/admin/authored-challenges/{challenge_id}/curation",
 947            params=_curation_params(competition_id, reason),
 948        )
 949        _raise_for_status(resp)
 950        return AdminAuthoredCurationRow.model_validate(resp.json())
 951
 952    def uncurate(
 953        self,
 954        challenge_id: str,
 955        competition_id: str = "",
 956        *,
 957        reason: str = "",
 958        scores: AuthoredScoresChoice | None = None,
 959    ) -> AdminAuthoredCurationRow:
 960        """Take an upload back out of this competition's challenge set.
 961
 962        Carries neither of :meth:`curate`'s refusals: removing an id from
 963        a set that should not hold it is the repair, so gating it on the
 964        same conditions would keep the broken state. ``competition_id``
 965        names which competition; empty is the upload's own. ``reason`` is
 966        :meth:`curate`'s, for a locked competition in play (#2041).
 967
 968        ``scores`` (#2991): ``kept`` keeps the points already scored there;
 969        ``removed`` and ``None`` (the removal as it always was) do not.
 970        """
 971        params = _curation_params(competition_id, reason) or {}
 972        if scores:
 973            params["scores"] = scores
 974        resp = self._http.request(
 975            "DELETE",
 976            f"/admin/authored-challenges/{challenge_id}/curation",
 977            params=params or None,
 978        )
 979        _raise_for_status(resp)
 980        return AdminAuthoredCurationRow.model_validate(resp.json())
 981
 982    def bulk_curate(
 983        self,
 984        competition_id: str,
 985        target_competition_ids: builtins.list[str],
 986        *,
 987        challenge_ids: builtins.list[str] | None = None,
 988        lock_versions: bool = True,
 989        dry_run: bool = False,
 990        reason: str = "",
 991    ) -> BulkCurationResponse:
 992        """Add ``competition_id``'s approved uploads to each target (#2120).
 993
 994        Each (upload, target) pair is :meth:`curate`'s press, with its
 995        refusals, and gets one ``outcome``; ``challenge_ids`` narrows the
 996        uploads. ``lock_versions`` turns each target's version lock on after.
 997        ``reason`` confirms changes to a target locked in play (#2041).
 998        ``dry_run=True`` is the preview.
 999        """
1000        resp = self._http.request(
1001            "POST",
1002            f"/admin/competitions/{competition_id}/authored-challenges/bulk-curation",
1003            json={
1004                "target_competition_ids": list(target_competition_ids),
1005                "challenge_ids": challenge_ids,
1006                "lock_versions": lock_versions,
1007                "dry_run": dry_run,
1008                "confirm": bool(reason),
1009                "reason": reason,
1010            },
1011        )
1012        _raise_for_status(resp)
1013        return BulkCurationResponse.model_validate(resp.json())
1014
1015    def revisions(self, challenge_id: str) -> builtins.list[AdminAuthoredRevisionRow]:
1016        """Every revision of one upload, newest first — the proposal, if
1017        any, and the history of those applied (plan P3.1)."""
1018        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/revisions")
1019        _raise_for_status(resp)
1020        return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()]
1021
1022    def revision_diff(self, challenge_id: str, revision_id: str) -> AuthoredRevisionDiff:
1023        """A proposed revision against the live tree — what a reviewer reads.
1024
1025        ⚠️ A changed file with an empty ``diff`` says why in ``omitted``
1026        (``binary``, ``too_large``, ``over_budget``); read such a file
1027        whole with :meth:`read_revision_file`. ``needs_exec_review`` says
1028        whether :meth:`apply_revision` will ask you to confirm the
1029        execution-surface review.
1030        """
1031        resp = self._http.request(
1032            "GET", f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/diff"
1033        )
1034        _raise_for_status(resp)
1035        return AuthoredRevisionDiff.model_validate(resp.json())
1036
1037    def read_revision_file(
1038        self, challenge_id: str, revision_id: str, path: str
1039    ) -> AuthoredChallengeFileBody:
1040        """One file of a revision, whole — attacker-authored text written
1041        to be run, like :meth:`read_file`'s."""
1042        resp = self._http.request(
1043            "POST",
1044            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/file",
1045            json={"path": path},
1046        )
1047        _raise_for_status(resp)
1048        return AuthoredChallengeFileBody.model_validate(resp.json())
1049
1050    def review_revision(
1051        self, challenge_id: str, revision_id: str, *, status: ReviewStatus, note: str = ""
1052    ) -> AdminAuthoredRevisionRow:
1053        """Record a verdict on a proposed revision. ``rejected`` requires a
1054        note and ``pending`` withdraws a verdict, as for an upload. An
1055        applied revision is history and is refused (409)."""
1056        resp = self._http.request(
1057            "POST",
1058            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/review",
1059            json={"status": status, "note": note},
1060        )
1061        _raise_for_status(resp)
1062        return AdminAuthoredRevisionRow.model_validate(resp.json())
1063
1064    def apply_revision(
1065        self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False
1066    ) -> AdminAuthoredChallengeRow:
1067        """Make an approved revision the upload's live version (plan P3.1).
1068
1069        A published upload's directory is replaced whole — boxes already
1070        running keep the bytes they started with — and the catalog is
1071        reloaded. Nodes that hold the upload keep the previous tree until
1072        :meth:`push` sends the new one.
1073
1074        ``exec_surface_reviewed`` confirms you read the revision's files
1075        marked ``executes``: a published upload whose revision has any is
1076        refused without it, every time — the bytes are new. The 409 carries
1077        every reason at once.
1078        """
1079        resp = self._http.request(
1080            "POST",
1081            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/apply",
1082            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
1083        )
1084        _raise_for_status(resp)
1085        return AdminAuthoredChallengeRow.model_validate(resp.json())
1086
1087
1088def _curation_params(competition_id: str, reason: str) -> dict[str, str] | None:
1089    """The curation verb's query: its target, and a locked event's reason.
1090
1091    A reason is the confirmation (#2041): an SDK caller who wrote one has
1092    decided, so it is sent with ``confirm`` rather than as a second flag.
1093    """
1094    params: dict[str, str] = {}
1095    if competition_id:
1096        params["competition_id"] = competition_id
1097    if reason.strip():
1098        params["confirm"] = "true"
1099        params["reason"] = reason
1100    return params or None
class AdminAuthoredChallengesResource:
  76class AdminAuthoredChallengesResource:
  77    """List, read and rule on player-authored uploads."""
  78
  79    def __init__(self, http: BaseHttpClient) -> None:
  80        self._http = http
  81
  82    def list(
  83        self,
  84        *,
  85        competition_id: str | None = None,
  86        team_id: str | None = None,
  87        review_status: ReviewStatus | None = None,
  88        smoke: AuthoredSmokeFilter | None = None,
  89        revision: ReviewStatus | None = None,
  90        order: AuthoredQueueOrder | None = None,
  91        destination: AuthoredDestination | None = None,
  92        offset: int = 0,
  93        limit: int = 50,
  94    ) -> PagedList[AdminAuthoredChallengeRow]:
  95        """The queue, oldest first.
  96
  97        ``None`` on a filter means *every*; ``""`` is a value, so
  98        passing it asks for the standalone catalog rather than for
  99        everything.
 100
 101        ``smoke`` narrows by what the smoke run found: a verdict,
 102        ``not_run`` for none yet, or ``in_flight`` for a run queued or
 103        running (each row's ``smoke_in_flight`` says which). The total is
 104        counted over the same filter.
 105
 106        ``revision`` narrows to uploads whose proposed revision carries
 107        that verdict (plan P3.1): ``pending`` waits for a reviewer,
 108        ``approved`` for :meth:`apply_revision`.
 109
 110        ``order="reputation"`` (plan P3.5) puts first the uploads of
 111        authors whose published challenges players received best — each
 112        row's ``author_reputation`` — then every upload with no reading,
 113        oldest first; ``None`` leaves the server's order, oldest first. It
 114        is ordered before the page is cut, so ``offset`` pages through
 115        that order.
 116
 117        ``destination`` narrows to where the author sent the upload (plan
 118        P3.4): ``public`` or ``eval_only``, the evaluation pool.
 119        """
 120        params: dict[str, object] = {"offset": offset, "limit": limit}
 121        if competition_id is not None:
 122            params["competition_id"] = competition_id
 123        if team_id is not None:
 124            params["team_id"] = team_id
 125        if review_status is not None:
 126            params["review_status"] = review_status
 127        if smoke is not None:
 128            params["smoke"] = smoke
 129        if revision is not None:
 130            params["revision"] = revision
 131        if order is not None:
 132            params["order"] = order
 133        if destination is not None:
 134            params["destination"] = destination
 135        resp = self._http.request("GET", "/admin/authored-challenges", params=params)
 136        _raise_for_status(resp)
 137        return _extract_items(resp.json(), AdminAuthoredChallengeRow)
 138
 139    def queue_counts(self, *, competition_id: str | None = None) -> AuthoredQueueCounts:
 140        """How many uploads each review tab holds and how long the longest
 141        has waited — one request, counted server-side (#2037 B1)."""
 142        params: dict[str, object] = {}
 143        if competition_id is not None:
 144            params["competition_id"] = competition_id
 145        resp = self._http.request("GET", "/admin/authored-challenges/queue-counts", params=params)
 146        _raise_for_status(resp)
 147        return AuthoredQueueCounts.model_validate(resp.json())
 148
 149    def build_capability(self) -> AuthoredBuildCapability:
 150        """Whether an approval on this deployment can become a live challenge.
 151
 152        ⚠️ **Ask this before reading a verdict as a deployment.** An
 153        ``approved`` row is a person saying they read the code; it is not
 154        a challenge in front of anybody until this answers ``can_build``.
 155
 156        ⚠️ **This line used to end "and today it never does".** That was
 157        true until `authored_sync` landed and is not any more: with a
 158        node marked ``untrusted_builds`` the answer is now ``True``, and
 159        a caller that still reads it as a constant would skip the one
 160        check that distinguishes a deployment which can run an approved
 161        upload from one which cannot.
 162
 163        ⚠️ **It answers for the deployment, never for a tree.** Whether
 164        *this* challenge reached a node is `push`'s task and its
 165        per-node lines.
 166        """
 167        resp = self._http.request("GET", "/admin/authoring/build-capability")
 168        _raise_for_status(resp)
 169        return AuthoredBuildCapability.model_validate(resp.json())
 170
 171    def get(self, challenge_id: str) -> AdminAuthoredChallengeDetail:
 172        """One upload, with the paths and sizes of what is in it."""
 173        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}")
 174        _raise_for_status(resp)
 175        return AdminAuthoredChallengeDetail.model_validate(resp.json())
 176
 177    def flow(self, challenge_id: str) -> AuthoredFlow:
 178        """Where this upload is in its lifecycle, what a platform admin can do
 179        next, and every transition that got it here — names included (#2147)."""
 180        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/flow")
 181        _raise_for_status(resp)
 182        return AuthoredFlow.model_validate(resp.json())
 183
 184    def precheck_runs(self, challenge_id: str) -> PrecheckRunsInfo:
 185        """The AI pre-check's managed-eval runs of this upload's pending
 186        versions, newest first (#3189)."""
 187        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/precheck-runs")
 188        _raise_for_status(resp)
 189        return PrecheckRunsInfo.model_validate(resp.json())
 190
 191    def precheck_run_artifact(self, challenge_id: str, run_id: str) -> bytes:
 192        """What one pre-check run's agent left behind, as ``.tar.gz`` bytes
 193        (#3260). Agent-written content: read it, never serve it."""
 194        resp = self._http.request(
 195            "GET", f"/admin/authored-challenges/{challenge_id}/precheck-runs/{run_id}/artifact"
 196        )
 197        _raise_for_status(resp)
 198        return bytes(resp.content)
 199
 200    def history(self, challenge_id: str) -> AuthoredHistory:
 201        """Everything that happened to this upload, oldest first, as one
 202        stream (#3093): the transition log, every AI pre-check and every
 203        injection review. Organisers and reviewers only."""
 204        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/history")
 205        _raise_for_status(resp)
 206        return AuthoredHistory.model_validate(resp.json())
 207
 208    def precheck_reruns(self, competition_id: str) -> PrecheckRerunInfo:
 209        """The event's re-check card (#3266): the two scopes' counts and the
 210        newest batch. Organisers only."""
 211        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/precheck-reruns")
 212        _raise_for_status(resp)
 213        return PrecheckRerunInfo.model_validate(resp.json())
 214
 215    def start_precheck_reruns(
 216        self, competition_id: str, *, scope: RerunScope = "unprechecked"
 217    ) -> PrecheckRerunInfo:
 218        """Re-run the AI pre-check over the event's approved uploads in
 219        *scope* (#3266). Evidence only: nothing it finds unpublishes one."""
 220        resp = self._http.request(
 221            "POST",
 222            f"/admin/competitions/{competition_id}/precheck-reruns",
 223            json={"scope": scope},
 224        )
 225        _raise_for_status(resp)
 226        return PrecheckRerunInfo.model_validate(resp.json())
 227
 228    def stop_precheck_reruns(self, competition_id: str, rerun_id: str) -> PrecheckRerunInfo:
 229        """Stop a running re-check batch; what finished is kept (#3266)."""
 230        resp = self._http.request(
 231            "POST", f"/admin/competitions/{competition_id}/precheck-reruns/{rerun_id}/stop"
 232        )
 233        _raise_for_status(resp)
 234        return PrecheckRerunInfo.model_validate(resp.json())
 235
 236    def smoke_logs(self, challenge_id: str) -> builtins.list[AuthoredSmokeLogSummary]:
 237        """The smoke runs kept for this upload, newest first (#2024) — the
 238        same scrubbed steps its author reads. The whole set: retention, not
 239        a page, bounds it."""
 240        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/smoke-logs")
 241        _raise_for_status(resp)
 242        return [AuthoredSmokeLogSummary.model_validate(row) for row in resp.json()]
 243
 244    def versions(self, challenge_id: str) -> builtins.list[AdminAuthoredVersionRow]:
 245        """Every stored version of this upload, newest first (#2037) — the
 246        rows its author reads, plus who ruled on each. The whole set."""
 247        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/versions")
 248        _raise_for_status(resp)
 249        return [AdminAuthoredVersionRow.model_validate(row) for row in resp.json()]
 250
 251    def competitions(self, challenge_id: str) -> builtins.list[AuthoredCompetitionRow]:
 252        """Every competition an upload is curated into, with what players did
 253        there (#2037) — starts and solves, counted over the whole set."""
 254        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/competitions")
 255        _raise_for_status(resp)
 256        return [AuthoredCompetitionRow.model_validate(row) for row in resp.json()]
 257
 258    def version_diff(
 259        self, challenge_id: str, *, from_version: str, to_version: str
 260    ) -> AuthoredVersionDiff:
 261        """Two versions of this upload compared (#2037) — the author's diff."""
 262        resp = self._http.request(
 263            "GET",
 264            f"/admin/authored-challenges/{challenge_id}/versions/diff",
 265            params={"from_version": from_version, "to_version": to_version},
 266        )
 267        _raise_for_status(resp)
 268        return AuthoredVersionDiff.model_validate(resp.json())
 269
 270    def version_archive(self, challenge_id: str, version_id: str) -> bytes:
 271        """One version as ``.tar.gz`` bytes, execute bits included (#2037)."""
 272        resp = self._http.request(
 273            "GET", f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/archive"
 274        )
 275        _raise_for_status(resp)
 276        return bytes(resp.content)
 277
 278    def version_switches(self, challenge_id: str) -> AuthoredVersionSwitchPlan:
 279        """Every switch of this upload's live version, newest first, and the
 280        running competitions holding it (#2037)."""
 281        resp = self._http.request(
 282            "GET", f"/admin/authored-challenges/{challenge_id}/version-switches"
 283        )
 284        _raise_for_status(resp)
 285        return AuthoredVersionSwitchPlan.model_validate(resp.json())
 286
 287    def switch_version(
 288        self,
 289        challenge_id: str,
 290        version_id: str,
 291        *,
 292        when: AuthoredVersionSwitchWhen = "now",
 293        reason: str = "",
 294        exec_surface_reviewed: bool = False,
 295        force: bool = False,
 296        force_reason: str = "",
 297    ) -> AuthoredVersionSwitchInfo:
 298        """Make a version live (#2037): an approved proposal is applied, an
 299        earlier approved version rolled back to (``reason`` required). ``when``
 300        is ``now`` or ``after_competitions`` — applied automatically once no
 301        running competition holds the challenge. Refused
 302        ``authored_version_switch_refused`` with coded reasons.
 303
 304        ``force`` / ``force_reason`` push an immediate switch through a
 305        competition that locks its challenges' versions while in play
 306        (#2041): a platform administrator's act, and only with a reason."""
 307        resp = self._http.request(
 308            "POST",
 309            f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/switch",
 310            json={
 311                "when": when,
 312                "reason": reason,
 313                "exec_surface_reviewed": exec_surface_reviewed,
 314                "force": force,
 315                "force_reason": force_reason,
 316            },
 317        )
 318        _raise_for_status(resp)
 319        return AuthoredVersionSwitchInfo.model_validate(resp.json())
 320
 321    def cancel_switch(self, challenge_id: str, switch_id: str) -> AuthoredVersionSwitchInfo:
 322        """Cancel a switch still waiting for its competitions to end (#2037)."""
 323        resp = self._http.request(
 324            "POST",
 325            f"/admin/authored-challenges/{challenge_id}/version-switches/{switch_id}/cancel",
 326        )
 327        _raise_for_status(resp)
 328        return AuthoredVersionSwitchInfo.model_validate(resp.json())
 329
 330    def version_messages(
 331        self, challenge_id: str, version_id: str
 332    ) -> builtins.list[AuthoredVersionMessage]:
 333        """One version's thread between its authors and reviewers (#2037)."""
 334        resp = self._http.request(
 335            "GET", f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/messages"
 336        )
 337        _raise_for_status(resp)
 338        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
 339
 340    def post_version_message(
 341        self, challenge_id: str, version_id: str, body: str, *, as_review_group: bool = True
 342    ) -> builtins.list[AuthoredVersionMessage]:
 343        """Write to an upload's authors on one version's thread (#2037) — as
 344        the review group unless ``as_review_group`` is false. The thread."""
 345        resp = self._http.request(
 346            "POST",
 347            f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/messages",
 348            json={"body": body, "as_review_group": as_review_group},
 349        )
 350        _raise_for_status(resp)
 351        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]
 352
 353    def version_storage(self) -> AuthoredVersionStorageInfo:
 354        """What the version store holds (#2037): ``stored_bytes`` each
 355        distinct file once, ``logical_bytes`` every version stored whole."""
 356        resp = self._http.request("GET", "/admin/authoring/version-storage")
 357        _raise_for_status(resp)
 358        return AuthoredVersionStorageInfo.model_validate(resp.json())
 359
 360    def smoke_log(self, challenge_id: str, log_id: str) -> AuthoredSmokeLog:
 361        """One smoke run of this upload, step by step (#2024)."""
 362        resp = self._http.request(
 363            "GET", f"/admin/authored-challenges/{challenge_id}/smoke-logs/{log_id}"
 364        )
 365        _raise_for_status(resp)
 366        return AuthoredSmokeLog.model_validate(resp.json())
 367
 368    def smoke_live(self, challenge_id: str) -> AuthoredSmokeLive:
 369        """The smoke run of this upload that is queued or running now (#2145)."""
 370        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/smoke-live")
 371        _raise_for_status(resp)
 372        return AuthoredSmokeLive.model_validate(resp.json())
 373
 374    def cancel_smoke(self, challenge_id: str) -> AuthoredSmokeLive:
 375        """Take back this upload's smoke run while it still waits (#2145)."""
 376        resp = self._http.request(
 377            "POST", f"/admin/authored-challenges/{challenge_id}/smoke-live/cancel"
 378        )
 379        _raise_for_status(resp)
 380        return AuthoredSmokeLive.model_validate(resp.json())
 381
 382    def smoke_run_lines(
 383        self,
 384        challenge_id: str,
 385        run_id: str,
 386        key: str,
 387        *,
 388        before: int = 0,
 389        after: int = 0,
 390        limit: int = 200,
 391    ) -> AuthoredSmokeStepLines:
 392        """A window of one step's full output (#2145) — the same scrubbed
 393        lines its author reads; cursors as ``client.authoring.smoke_run_lines``."""
 394        resp = self._http.request(
 395            "GET",
 396            f"/admin/authored-challenges/{challenge_id}/smoke-runs/{run_id}/lines",
 397            params={"step": key, "before": before, "after": after, "limit": limit},
 398        )
 399        _raise_for_status(resp)
 400        return AuthoredSmokeStepLines.model_validate(resp.json())
 401
 402    def audit(self, challenge_id: str) -> AuthoredAuditReport:
 403        """Run the challenge audit over this upload and report it.
 404
 405        ⚠️ **Evidence, never a gate.** :meth:`review` does not consult
 406        this: a rule that refused an approval on a finding would make
 407        the operator's judgement the audit's to overrule, and the audit
 408        cannot see inside a `Dockerfile`'s `RUN` — which is the half a
 409        person is reading the files for.
 410
 411        ⚠️ Read ``outcome`` before the counts. An empty ``findings``
 412        list means *nothing was found* only when it says ``ok``;
 413        ``timed_out`` and ``failed`` are the audit declining to answer,
 414        and reading either as clean is the whole reason the field
 415        exists.
 416        """
 417        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/audit")
 418        _raise_for_status(resp)
 419        return AuthoredAuditReport.model_validate(resp.json())
 420
 421    def model_review(self, challenge_id: str) -> AdminModelReviewInfo:
 422        """The model's reading of this upload and of its proposed revision
 423        (#1965 D): the injection verdict with its full reasoning and the
 424        spans it quoted, the advisory findings, and near copies.
 425
 426        ⚠️ Read ``effective_injection`` to know what the platform acts on —
 427        ``injection`` is what the model said, and an override makes it
 428        ``clean``. ``unknown`` is the platform's problem (``problem`` says
 429        which) and never blocks.
 430        """
 431        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/model-review")
 432        _raise_for_status(resp)
 433        return AdminModelReviewInfo.model_validate(resp.json())
 434
 435    def run_model_review(self, challenge_id: str, *, revision_id: str = "") -> AdminTaskInfo:
 436        """Ask the model to read this upload, or its proposed revision —
 437        advice: the press decides nothing. 409 while one is in flight."""
 438        params = {"revision_id": revision_id} if revision_id else None
 439        resp = self._http.request(
 440            "POST", f"/admin/authored-challenges/{challenge_id}/model-review", params=params
 441        )
 442        _raise_for_status(resp)
 443        return AdminTaskInfo.model_validate(resp.json())
 444
 445    def assistant_review(self, challenge_id: str) -> AdminProbeInfo:
 446        """The reviewer assistant's newest run on this upload's live tree and
 447        on its proposed revision (#2830) — advice for the person reviewing,
 448        never a verdict. ``enabled`` is whether this deployment names a
 449        model at all."""
 450        resp = self._http.request(
 451            "GET", f"/admin/authored-challenges/{challenge_id}/assistant-review"
 452        )
 453        _raise_for_status(resp)
 454        return AdminProbeInfo.model_validate(resp.json())
 455
 456    def rerun_assistant_review(self, challenge_id: str) -> AdminTaskInfo:
 457        """Smoke this upload again with the AI pre-check, even though this
 458        version was probed already (#2830, #3053). A rerun *is* a smoke, so it
 459        takes :meth:`smoke`'s door (super-admin); ``409 authoring_probe_off``
 460        while the event chose no model, ``authoring_probe_not_isolated`` with
 461        no quarantine or dedicated node, ``authoring_probe_budget_spent``
 462        once the version's cap is spent."""
 463        resp = self._http.request(
 464            "POST", f"/admin/authored-challenges/{challenge_id}/assistant-review"
 465        )
 466        _raise_for_status(resp)
 467        return AdminTaskInfo.model_validate(resp.json())
 468
 469    def override_model_review(
 470        self, challenge_id: str, review_id: str, *, note: str
 471    ) -> AdminModelReview:
 472        """Say the text a review flagged is not an injection. ``note`` is
 473        required — the audit's *why*. An upload or revision the verdict
 474        rejected under automatic review is decided again."""
 475        resp = self._http.request(
 476            "POST",
 477            f"/admin/authored-challenges/{challenge_id}/model-review/{review_id}/override",
 478            json={"note": note},
 479        )
 480        _raise_for_status(resp)
 481        return AdminModelReview.model_validate(resp.json())
 482
 483    def malice_review(self, challenge_id: str) -> AdminMaliceInfo:
 484        """The malice check of this upload's live tree and of its proposed
 485        revision (#3211): ``hold``, every reason, the rule hits, the model's
 486        reading and quotes, and any ruling. Never calls a model."""
 487        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/malice-review")
 488        _raise_for_status(resp)
 489        return AdminMaliceInfo.model_validate(resp.json())
 490
 491    def override_malice_review(
 492        self,
 493        challenge_id: str,
 494        review_id: str,
 495        *,
 496        decision: Literal["clear", "confirm"],
 497        note: str,
 498    ) -> AdminMaliceStanding:
 499        """Rule on a malice hold: ``clear`` releases it (a version the
 500        automatic chain held is decided again), ``confirm`` keeps it.
 501        ``note`` is required — the audit's *why*."""
 502        resp = self._http.request(
 503            "POST",
 504            f"/admin/authored-challenges/{challenge_id}/malice-review/{review_id}/override",
 505            json={"decision": decision, "note": note},
 506        )
 507        _raise_for_status(resp)
 508        return AdminMaliceStanding.model_validate(resp.json())
 509
 510    def read_file(self, challenge_id: str, path: str) -> AuthoredChallengeFileBody:
 511        """One file's contents.
 512
 513        ⚠️ The body is **attacker-authored text written to be run**.
 514        ``binary`` and ``truncated`` say what you are not looking at;
 515        treat a ``False`` on either as the only claim that you are.
 516        """
 517        # ⚠️ The path rides the body, never the URL (#1929): a WAF that
 518        # refuses a URL ending in ``.ini`` does not read a JSON body.
 519        resp = self._http.request(
 520            "POST", f"/admin/authored-challenges/{challenge_id}/file", json={"path": path}
 521        )
 522        _raise_for_status(resp)
 523        return AuthoredChallengeFileBody.model_validate(resp.json())
 524
 525    def operator_revise(
 526        self,
 527        challenge_id: str,
 528        *,
 529        archive: bytes | None = None,
 530        put: Mapping[str, bytes] | None = None,
 531        delete: builtins.list[str] | None = None,
 532        note: str = "",
 533        approve: bool = False,
 534        replace_waiting: bool = False,
 535    ) -> AuthoredOperatorRevisionInfo:
 536        """Submit a new version of somebody else's upload on their behalf (#3002).
 537
 538        Exactly one way in: ``archive`` (a whole ``.tar.gz``), or edits on top
 539        of the current tree — ``put`` maps a path to its new bytes, ``delete``
 540        names paths to remove. The tree meets every rule an upload's does;
 541        the author stays the author, the version records you as
 542        ``revised_by``, and the uploader is told which files changed.
 543        ``approve`` approves it in the same press, under your name.
 544        ``replace_waiting`` confirms replacing a revision the author already
 545        has waiting (otherwise ``409 authored_operator_revision_replaces_proposal``).
 546        """
 547        data: dict[str, str] = {
 548            "note": note,
 549            "approve": "true" if approve else "",
 550            "replace_waiting": "true" if replace_waiting else "",
 551        }
 552        files: dict[str, tuple[str, bytes, str]] = {}
 553        if archive is not None:
 554            files["file"] = ("revision.tar.gz", archive, "application/gzip")
 555        else:
 556            edits = [
 557                {"path": path, "content_b64": base64.b64encode(body).decode()}
 558                for path, body in (put or {}).items()
 559            ] + [{"path": path, "content_b64": None} for path in (delete or [])]
 560            data["edits"] = json.dumps(edits)
 561        resp = self._http.request(
 562            "POST",
 563            f"/admin/authored-challenges/{challenge_id}/operator-revisions",
 564            data=data,
 565            files=files or None,
 566        )
 567        _raise_for_status(resp)
 568        return AuthoredOperatorRevisionInfo.model_validate(resp.json())
 569
 570    def graduation(self, challenge_id: str, corpus_id: str = "") -> AuthoredGraduationInfo:
 571        """Whether an upload may graduate into the corpus, and as what (plan P3.3).
 572
 573        ``refusals`` names every reason it may not yet — empty when it may.
 574        ``corpus_id`` is the id it would take: the one passed, or else the
 575        next ``COMMUNITY-NNN`` this deployment's catalog does not hold. The
 576        pull request's title and body are what a person opens it with.
 577        """
 578        params = {"corpus_id": corpus_id} if corpus_id else None
 579        resp = self._http.request(
 580            "GET", f"/admin/authored-challenges/{challenge_id}/graduation", params=params
 581        )
 582        _raise_for_status(resp)
 583        return AuthoredGraduationInfo.model_validate(resp.json())
 584
 585    def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes:
 586        """The ``.tar.gz`` to unpack at the corpus repository's root.
 587
 588        It holds ``COMMUNITY/<corpus_id>/``: the tree as reviewed, the
 589        ``LICENSE`` its author declared and a ``CREDITS.md``. Nothing is
 590        recorded on the platform — opening the pull request is a person's
 591        act, in another repository.
 592        """
 593        resp = self._http.request(
 594            "GET",
 595            f"/admin/authored-challenges/{challenge_id}/graduation/bundle",
 596            params={"corpus_id": corpus_id},
 597        )
 598        _raise_for_status(resp)
 599        return resp.content
 600
 601    def review(
 602        self,
 603        challenge_id: str,
 604        *,
 605        status: ReviewStatus,
 606        note: str = "",
 607        exec_surface_reviewed: bool = False,
 608    ) -> AdminAuthoredChallengeRow:
 609        """Record a verdict. ``rejected`` requires a note.
 610
 611        ``pending`` withdraws a verdict and clears its trail — a
 612        mis-clicked approval is undoable without deleting the upload.
 613        ``exec_surface_reviewed`` (#3337) confirms, with an approval, that
 614        you read what the tree runs; with your verdict that is a person's
 615        release, which replaces an automatic review's policy release.
 616        """
 617        resp = self._http.request(
 618            "POST",
 619            f"/admin/authored-challenges/{challenge_id}/review",
 620            json={"status": status, "note": note},
 621            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
 622        )
 623        _raise_for_status(resp)
 624        return AdminAuthoredChallengeRow.model_validate(resp.json())
 625
 626    def push(self, challenge_id: str) -> AdminTaskInfo:
 627        """Send an approved upload to every node marked for untrusted builds.
 628
 629        ⚠️ **The verb after `publish`, and they are not the same act.**
 630        `publish` writes the tree into *this deployment's* catalog root;
 631        a multi-host node's catalog is a different directory on a
 632        different machine, and until this runs the node answers 404 for
 633        the id the platform now lists.
 634
 635        Returns the task: the push is per node, and which node refused
 636        is on its log rather than in one summary line.
 637        """
 638        resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/push")
 639        _raise_for_status(resp)
 640        return AdminTaskInfo.model_validate(resp.json())
 641
 642    def smoke(
 643        self, challenge_id: str, *, build_network: BuildNetwork | None = None
 644    ) -> AdminTaskInfo:
 645        """Run this upload's own reference exploit against it, on a node.
 646
 647        ⚠️ **The machine check an approval cannot make.** The audit is
 648        static and a reviewer reads files; only a run says whether the
 649        estate comes up and the exploit captures what it claims.
 650
 651        Returns the task — the verdict is on its log, and it is
 652        deliberately four-valued: *solved*, *unsolved*, *estate_failed*,
 653        *malformed*, plus *errored* for a node that could not tell.
 654
 655        #2873: ``build_network`` (``online`` / ``offline``) is whether this
 656        run's build ``RUN`` steps reach the network; ``None`` is the upload's
 657        competition's setting.
 658        """
 659        resp = self._http.request(
 660            "POST",
 661            f"/admin/authored-challenges/{challenge_id}/smoke",
 662            params={"build_network": build_network} if build_network else None,
 663        )
 664        _raise_for_status(resp)
 665        return AdminTaskInfo.model_validate(resp.json())
 666
 667    def resmoke_platform_faults(
 668        self, *, competition_id: str = "", dry_run: bool = False
 669    ) -> AdminTaskInfo:
 670        """Re-smoke every upload #2029 mislabelled, once (the operator one-shot).
 671
 672        A dropped execute bit and a run-time ``pip install`` on a no-egress
 673        smoke network recorded the author's ``unsolved`` / ``estate_failed``;
 674        both are fixed. This enqueues the backfill, which selects those
 675        uploads, re-runs each uncharged, and mails each author once that the
 676        earlier failure was the platform's. ``dry_run`` reports the selection
 677        and runs nothing. Returns the task.
 678        """
 679        resp = self._http.request(
 680            "POST",
 681            "/admin/authored-challenges/resmoke-platform-faults",
 682            params={"competition_id": competition_id, "dry_run": dry_run},
 683        )
 684        _raise_for_status(resp)
 685        return AdminTaskInfo.model_validate(resp.json())
 686
 687    def strays(self) -> builtins.list[AuthoredStrayRow]:
 688        """Every stored challenge id a competition may not hold (#2553): an
 689        invitation-only event's upload in another event's list or map — what
 690        :meth:`strip_strays` removes. Read-only; empty is clean. (Before an
 691        upgrade the check is offline: ``ctfy-admin admin authored strays --db``.)"""
 692        resp = self._http.request("GET", "/admin/authored-challenges/strays")
 693        _raise_for_status(resp)
 694        return [AuthoredStrayRow.model_validate(row) for row in resp.json()]
 695
 696    def strip_strays(self, *, apply: bool = False) -> AuthoredStripResult:
 697        """Remove every stray (#2553) — a dry run unless ``apply``. Each
 698        removal is recorded with the platform as actor and keeps its solves'
 699        points; the boxes it left behind are reclaimed."""
 700        resp = self._http.request(
 701            "POST",
 702            "/admin/authored-challenges/strays/strip",
 703            params={"apply": "true" if apply else "false"},
 704        )
 705        _raise_for_status(resp)
 706        return AuthoredStripResult.model_validate(resp.json())
 707
 708    def publish(
 709        self, challenge_id: str, *, exec_surface_reviewed: bool = False
 710    ) -> AdminAuthoredChallengeRow:
 711        """Write an approved upload into the deployment's catalog.
 712
 713        A separate verb from :meth:`review` on purpose: approving is a
 714        person's verdict on somebody else's code, and it must not fail
 715        because a corpus commit landed on the id in the days since. The
 716        409 this raises carries every reason at once — the id is taken,
 717        the row is not approved, the tree has no ``metadata.yaml`` — so a
 718        caller fixes them in one pass rather than one press at a time.
 719
 720        ``exec_surface_reviewed`` confirms you read the files the tree
 721        runs on a node or hands players to run (``executes`` on
 722        :meth:`get`'s file list, plan P2.3). A tree with such files is
 723        refused without it — once; the stamp then records who read it.
 724
 725        The row that comes back is **read back from the store**, so its
 726        ``in_catalog`` is a fact about the disk after the write.
 727        """
 728        resp = self._http.request(
 729            "POST",
 730            f"/admin/authored-challenges/{challenge_id}/publish",
 731            # Only what was said: absent is the server's own default.
 732            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
 733        )
 734        _raise_for_status(resp)
 735        return AdminAuthoredChallengeRow.model_validate(resp.json())
 736
 737    def release_to_catalog(
 738        self, challenge_id: str, *, exec_surface_reviewed: bool = False
 739    ) -> AdminAuthoredChallengeRow:
 740        """Put a published upload in the **public catalog** (#1989).
 741
 742        ⚠️ **Publishing is not this.** A published upload is seen only in
 743        the competitions that curate it — by their players once each
 744        starts, their organisers, its authors and platform admins. This is
 745        the separate step that lists it to everybody; who released it and
 746        when lands on the row (``catalog_released_by`` /
 747        ``catalog_released_at``), and a second call changes nothing.
 748
 749        A tree an automatic review released is refused (409, with reasons)
 750        until a person has recorded a content verdict on it (:meth:`review`)
 751        and a platform admin has read what it runs — ``exec_surface_reviewed``
 752        confirms the second on this call.
 753        """
 754        resp = self._http.request(
 755            "POST",
 756            f"/admin/authored-challenges/{challenge_id}/catalog-release",
 757            # Only what was said: absent is the server's own default.
 758            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
 759        )
 760        _raise_for_status(resp)
 761        return AdminAuthoredChallengeRow.model_validate(resp.json())
 762
 763    def retire(
 764        self, challenge_id: str, *, scores: AuthoredScoresChoice | None = None
 765    ) -> AdminAuthoredChallengeRow:
 766        """Stop offering an upload; its history is kept (#2760).
 767
 768        Platform admins only. It leaves every competition and is no longer
 769        open to everyone; running environments run out on their own. A call
 770        on an upload already retired changes nothing.
 771
 772        ``scores`` (#2991) decides the points already scored on it: ``kept``
 773        (and ``None``, the server's default) or ``removed`` — see
 774        :meth:`points_at_stake` for what each would touch.
 775        """
 776        resp = self._http.request(
 777            "POST",
 778            f"/admin/authored-challenges/{challenge_id}/retire",
 779            params={"scores": scores} if scores else None,
 780        )
 781        _raise_for_status(resp)
 782        return AdminAuthoredChallengeRow.model_validate(resp.json())
 783
 784    def unretire(self, challenge_id: str) -> AdminAuthoredChallengeRow:
 785        """Offer a retired upload again (#2760): back to approved with no
 786        competition using it. A call on one that is not retired changes
 787        nothing."""
 788        resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/unretire")
 789        _raise_for_status(resp)
 790        return AdminAuthoredChallengeRow.model_validate(resp.json())
 791
 792    def other_uploads(self, challenge_id: str) -> AuthoredOtherUploads:
 793        """The other uploads of this upload's challenge (#3110): same
 794        competition, same title, same team or author — and the one approved
 795        and smoked clean, suggested as the one to keep."""
 796        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/other-uploads")
 797        _raise_for_status(resp)
 798        return AuthoredOtherUploads.model_validate(resp.json())
 799
 800    def merge(
 801        self, challenge_id: str, *, into: str, reason: str, confirm: bool = False
 802    ) -> AdminAuthoredChallengeDetail:
 803        """Fold an upload into another upload of the same challenge (#3110):
 804        recorded as ``merged`` with ``reason``, and taken out of every
 805        competition's set (points scored there kept). Platform admins only.
 806        Refused while anything is in play; ``confirm`` lifts only a running
 807        event that would lose the challenge."""
 808        params: dict[str, str] = {"into": into, "reason": reason}
 809        if confirm:
 810            params["confirm"] = "true"
 811        resp = self._http.request(
 812            "POST", f"/admin/authored-challenges/{challenge_id}/merge", params=params
 813        )
 814        _raise_for_status(resp)
 815        return AdminAuthoredChallengeDetail.model_validate(resp.json())
 816
 817    def unmerge(self, challenge_id: str, *, reason: str = "") -> AdminAuthoredChallengeDetail:
 818        """Undo a merge (#3110): the upload is its own challenge again; no
 819        competition comes back by itself."""
 820        resp = self._http.request(
 821            "POST",
 822            f"/admin/authored-challenges/{challenge_id}/unmerge",
 823            params={"reason": reason} if reason else None,
 824        )
 825        _raise_for_status(resp)
 826        return AdminAuthoredChallengeDetail.model_validate(resp.json())
 827
 828    def unrelease_from_catalog(self, challenge_id: str) -> AdminAuthoredChallengeRow:
 829        """Withdraw an upload's public-catalog release (#2759): no longer
 830        open to everyone, only the competitions using it.
 831
 832        Platform admins only. Every competition use is kept — an upload no
 833        competition uses is then seen by nobody — and environments running
 834        outside a competition run out on their own. Recorded as an
 835        ``unreleased`` lifecycle record; a call on an upload that is not
 836        open to everyone changes nothing.
 837        """
 838        resp = self._http.request(
 839            "POST", f"/admin/authored-challenges/{challenge_id}/catalog-unrelease"
 840        )
 841        _raise_for_status(resp)
 842        return AdminAuthoredChallengeRow.model_validate(resp.json())
 843
 844    def withdraw_policy_release(self, challenge_id: str) -> AdminAuthoredChallengeRow:
 845        """Withdraw the policy release an automatic review wrote (#3337):
 846        the tree is no longer held to its event's dedicated pool, and starts
 847        nowhere until a person approves it with ``exec_surface_reviewed``
 848        (:meth:`review`). Platform admins only; recorded as
 849        ``policy_unreleased``; a call with no release changes nothing.
 850        """
 851        resp = self._http.request(
 852            "POST", f"/admin/authored-challenges/{challenge_id}/policy-unrelease"
 853        )
 854        _raise_for_status(resp)
 855        return AdminAuthoredChallengeRow.model_validate(resp.json())
 856
 857    def points_at_stake(
 858        self, challenge_id: str, action: AuthoredFlowAction, competition_id: str = ""
 859    ) -> AuthoredPointsAtStake:
 860        """What removing an upload would decide about the points already
 861        scored on it (#2991): for ``uncurate`` (in ``competition_id``, or its
 862        own), ``retire`` or ``unpublish``, the ranked solves and teams at
 863        stake and the effects of keeping and of removing them."""
 864        params: dict[str, str] = {"action": action}
 865        if competition_id:
 866            params["competition_id"] = competition_id
 867        resp = self._http.request(
 868            "GET", f"/admin/authored-challenges/{challenge_id}/points", params=params
 869        )
 870        _raise_for_status(resp)
 871        return AuthoredPointsAtStake.model_validate(resp.json())
 872
 873    def unpublish(
 874        self,
 875        challenge_id: str,
 876        *,
 877        reason: str,
 878        scores: AuthoredScoresChoice | None = None,
 879    ) -> AdminAuthoredChallengeRow:
 880        """Take a published challenge back out of the deployment's catalog.
 881
 882        ``reason`` is required (``422`` with ``unpublish_needs_reason`` when
 883        blank): the take-down is recorded — who, when, why — on the upload's
 884        publication history (``AdminAuthoredChallengeDetail.lifecycle``,
 885        #1965 H), which the authoring board reads.
 886
 887        :meth:`publish` had no inverse, so an upload that turned out to
 888        leak its own answer stayed in front of every entrant and the only
 889        recourse was a shell on the platform host.
 890
 891        It deletes the *publication*, never the row: the author's tree,
 892        the reviewer's verdict and the smoke result all survive, and
 893        publishing again is the same call it always was. The boxes already
 894        running on it are stopped too (the route's ``stop_instances``
 895        defaults on and this call does not send it), so nobody new can
 896        launch and nobody keeps playing it. It also leaves every competition
 897        that uses it (#2740 D7); allowing it again adds it back to none.
 898
 899        Raises 409 when the deployment's catalog does not hold the id,
 900        which is the mirror of publishing something already published.
 901
 902        ``scores`` (#2991): ``removed`` takes the points already scored on it
 903        off the board; ``kept`` and ``None`` keep them.
 904        """
 905        body: dict[str, str] = {"reason": reason}
 906        if scores:
 907            body["scores"] = scores
 908        resp = self._http.request(
 909            "POST",
 910            f"/admin/authored-challenges/{challenge_id}/unpublish",
 911            json=body,
 912        )
 913        _raise_for_status(resp)
 914        return AdminAuthoredChallengeRow.model_validate(resp.json())
 915
 916    def curate(
 917        self, challenge_id: str, competition_id: str = "", *, reason: str = ""
 918    ) -> AdminAuthoredCurationRow:
 919        """Put a published upload in front of this competition's entrants.
 920
 921        ⚠️ **The step after `publish`, and the two are not one act.**
 922        Publishing writes the tree into this deployment's catalog;
 923        curating adds its id to *one competition's* challenge set. Until
 924        this existed the only way to take it was the competition edit
 925        form, which re-sends the whole list — so two organisers working a
 926        review queue dropped each other's additions. This appends.
 927
 928        Refused (409, with reasons) for a competition whose set comes
 929        from a catalog bucket, and for an id this deployment has not
 930        published: the second would put a challenge in front of entrants
 931        that no node can start.
 932
 933        ``competition_id`` names another competition to curate it into —
 934        the workshop → arena step — and every refusal is then asked of
 935        that one. Empty is the upload's own competition.
 936
 937        ``warnings`` on the answer names what the target would refuse at its
 938        own upload door and this verb does not (#1965): a static answer
 939        curated into a competition reviewed automatically.
 940
 941        ``reason`` confirms the change for a competition that locks its
 942        challenges while in play (#2041) — refused
 943        (``curation_locked_needs_reason``) without one, and recorded with it.
 944        """
 945        resp = self._http.request(
 946            "POST",
 947            f"/admin/authored-challenges/{challenge_id}/curation",
 948            params=_curation_params(competition_id, reason),
 949        )
 950        _raise_for_status(resp)
 951        return AdminAuthoredCurationRow.model_validate(resp.json())
 952
 953    def uncurate(
 954        self,
 955        challenge_id: str,
 956        competition_id: str = "",
 957        *,
 958        reason: str = "",
 959        scores: AuthoredScoresChoice | None = None,
 960    ) -> AdminAuthoredCurationRow:
 961        """Take an upload back out of this competition's challenge set.
 962
 963        Carries neither of :meth:`curate`'s refusals: removing an id from
 964        a set that should not hold it is the repair, so gating it on the
 965        same conditions would keep the broken state. ``competition_id``
 966        names which competition; empty is the upload's own. ``reason`` is
 967        :meth:`curate`'s, for a locked competition in play (#2041).
 968
 969        ``scores`` (#2991): ``kept`` keeps the points already scored there;
 970        ``removed`` and ``None`` (the removal as it always was) do not.
 971        """
 972        params = _curation_params(competition_id, reason) or {}
 973        if scores:
 974            params["scores"] = scores
 975        resp = self._http.request(
 976            "DELETE",
 977            f"/admin/authored-challenges/{challenge_id}/curation",
 978            params=params or None,
 979        )
 980        _raise_for_status(resp)
 981        return AdminAuthoredCurationRow.model_validate(resp.json())
 982
 983    def bulk_curate(
 984        self,
 985        competition_id: str,
 986        target_competition_ids: builtins.list[str],
 987        *,
 988        challenge_ids: builtins.list[str] | None = None,
 989        lock_versions: bool = True,
 990        dry_run: bool = False,
 991        reason: str = "",
 992    ) -> BulkCurationResponse:
 993        """Add ``competition_id``'s approved uploads to each target (#2120).
 994
 995        Each (upload, target) pair is :meth:`curate`'s press, with its
 996        refusals, and gets one ``outcome``; ``challenge_ids`` narrows the
 997        uploads. ``lock_versions`` turns each target's version lock on after.
 998        ``reason`` confirms changes to a target locked in play (#2041).
 999        ``dry_run=True`` is the preview.
1000        """
1001        resp = self._http.request(
1002            "POST",
1003            f"/admin/competitions/{competition_id}/authored-challenges/bulk-curation",
1004            json={
1005                "target_competition_ids": list(target_competition_ids),
1006                "challenge_ids": challenge_ids,
1007                "lock_versions": lock_versions,
1008                "dry_run": dry_run,
1009                "confirm": bool(reason),
1010                "reason": reason,
1011            },
1012        )
1013        _raise_for_status(resp)
1014        return BulkCurationResponse.model_validate(resp.json())
1015
1016    def revisions(self, challenge_id: str) -> builtins.list[AdminAuthoredRevisionRow]:
1017        """Every revision of one upload, newest first — the proposal, if
1018        any, and the history of those applied (plan P3.1)."""
1019        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/revisions")
1020        _raise_for_status(resp)
1021        return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()]
1022
1023    def revision_diff(self, challenge_id: str, revision_id: str) -> AuthoredRevisionDiff:
1024        """A proposed revision against the live tree — what a reviewer reads.
1025
1026        ⚠️ A changed file with an empty ``diff`` says why in ``omitted``
1027        (``binary``, ``too_large``, ``over_budget``); read such a file
1028        whole with :meth:`read_revision_file`. ``needs_exec_review`` says
1029        whether :meth:`apply_revision` will ask you to confirm the
1030        execution-surface review.
1031        """
1032        resp = self._http.request(
1033            "GET", f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/diff"
1034        )
1035        _raise_for_status(resp)
1036        return AuthoredRevisionDiff.model_validate(resp.json())
1037
1038    def read_revision_file(
1039        self, challenge_id: str, revision_id: str, path: str
1040    ) -> AuthoredChallengeFileBody:
1041        """One file of a revision, whole — attacker-authored text written
1042        to be run, like :meth:`read_file`'s."""
1043        resp = self._http.request(
1044            "POST",
1045            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/file",
1046            json={"path": path},
1047        )
1048        _raise_for_status(resp)
1049        return AuthoredChallengeFileBody.model_validate(resp.json())
1050
1051    def review_revision(
1052        self, challenge_id: str, revision_id: str, *, status: ReviewStatus, note: str = ""
1053    ) -> AdminAuthoredRevisionRow:
1054        """Record a verdict on a proposed revision. ``rejected`` requires a
1055        note and ``pending`` withdraws a verdict, as for an upload. An
1056        applied revision is history and is refused (409)."""
1057        resp = self._http.request(
1058            "POST",
1059            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/review",
1060            json={"status": status, "note": note},
1061        )
1062        _raise_for_status(resp)
1063        return AdminAuthoredRevisionRow.model_validate(resp.json())
1064
1065    def apply_revision(
1066        self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False
1067    ) -> AdminAuthoredChallengeRow:
1068        """Make an approved revision the upload's live version (plan P3.1).
1069
1070        A published upload's directory is replaced whole — boxes already
1071        running keep the bytes they started with — and the catalog is
1072        reloaded. Nodes that hold the upload keep the previous tree until
1073        :meth:`push` sends the new one.
1074
1075        ``exec_surface_reviewed`` confirms you read the revision's files
1076        marked ``executes``: a published upload whose revision has any is
1077        refused without it, every time — the bytes are new. The 409 carries
1078        every reason at once.
1079        """
1080        resp = self._http.request(
1081            "POST",
1082            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/apply",
1083            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
1084        )
1085        _raise_for_status(resp)
1086        return AdminAuthoredChallengeRow.model_validate(resp.json())

List, read and rule on player-authored uploads.

AdminAuthoredChallengesResource(http: ctfy.sdk.base.BaseHttpClient)
79    def __init__(self, http: BaseHttpClient) -> None:
80        self._http = http
def list( self, *, competition_id: str | None = None, team_id: str | None = None, review_status: Optional[Literal['pending', 'approved', 'rejected']] = None, smoke: Union[Literal['not_run', 'in_flight'], Literal['solved', 'unsolved', 'estate_failed', 'malformed', 'errored'], NoneType] = None, revision: Optional[Literal['pending', 'approved', 'rejected']] = None, order: Optional[Literal['oldest', 'reputation']] = None, destination: Optional[Literal['public', 'eval_only']] = None, offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.AdminAuthoredChallengeRow]:
 82    def list(
 83        self,
 84        *,
 85        competition_id: str | None = None,
 86        team_id: str | None = None,
 87        review_status: ReviewStatus | None = None,
 88        smoke: AuthoredSmokeFilter | None = None,
 89        revision: ReviewStatus | None = None,
 90        order: AuthoredQueueOrder | None = None,
 91        destination: AuthoredDestination | None = None,
 92        offset: int = 0,
 93        limit: int = 50,
 94    ) -> PagedList[AdminAuthoredChallengeRow]:
 95        """The queue, oldest first.
 96
 97        ``None`` on a filter means *every*; ``""`` is a value, so
 98        passing it asks for the standalone catalog rather than for
 99        everything.
100
101        ``smoke`` narrows by what the smoke run found: a verdict,
102        ``not_run`` for none yet, or ``in_flight`` for a run queued or
103        running (each row's ``smoke_in_flight`` says which). The total is
104        counted over the same filter.
105
106        ``revision`` narrows to uploads whose proposed revision carries
107        that verdict (plan P3.1): ``pending`` waits for a reviewer,
108        ``approved`` for :meth:`apply_revision`.
109
110        ``order="reputation"`` (plan P3.5) puts first the uploads of
111        authors whose published challenges players received best — each
112        row's ``author_reputation`` — then every upload with no reading,
113        oldest first; ``None`` leaves the server's order, oldest first. It
114        is ordered before the page is cut, so ``offset`` pages through
115        that order.
116
117        ``destination`` narrows to where the author sent the upload (plan
118        P3.4): ``public`` or ``eval_only``, the evaluation pool.
119        """
120        params: dict[str, object] = {"offset": offset, "limit": limit}
121        if competition_id is not None:
122            params["competition_id"] = competition_id
123        if team_id is not None:
124            params["team_id"] = team_id
125        if review_status is not None:
126            params["review_status"] = review_status
127        if smoke is not None:
128            params["smoke"] = smoke
129        if revision is not None:
130            params["revision"] = revision
131        if order is not None:
132            params["order"] = order
133        if destination is not None:
134            params["destination"] = destination
135        resp = self._http.request("GET", "/admin/authored-challenges", params=params)
136        _raise_for_status(resp)
137        return _extract_items(resp.json(), AdminAuthoredChallengeRow)

The queue, oldest first.

None on a filter means every; "" is a value, so passing it asks for the standalone catalog rather than for everything.

smoke narrows by what the smoke run found: a verdict, not_run for none yet, or in_flight for a run queued or running (each row's smoke_in_flight says which). The total is counted over the same filter.

revision narrows to uploads whose proposed revision carries that verdict (plan P3.1): pending waits for a reviewer, approved for apply_revision().

order="reputation" (plan P3.5) puts first the uploads of authors whose published challenges players received best — each row's author_reputation — then every upload with no reading, oldest first; None leaves the server's order, oldest first. It is ordered before the page is cut, so offset pages through that order.

destination narrows to where the author sent the upload (plan P3.4): public or eval_only, the evaluation pool.

def queue_counts( self, *, competition_id: str | None = None) -> ctfy.server.models.AuthoredQueueCounts:
139    def queue_counts(self, *, competition_id: str | None = None) -> AuthoredQueueCounts:
140        """How many uploads each review tab holds and how long the longest
141        has waited — one request, counted server-side (#2037 B1)."""
142        params: dict[str, object] = {}
143        if competition_id is not None:
144            params["competition_id"] = competition_id
145        resp = self._http.request("GET", "/admin/authored-challenges/queue-counts", params=params)
146        _raise_for_status(resp)
147        return AuthoredQueueCounts.model_validate(resp.json())

How many uploads each review tab holds and how long the longest has waited — one request, counted server-side (#2037 B1).

def build_capability(self) -> ctfy.server.models.AuthoredBuildCapability:
149    def build_capability(self) -> AuthoredBuildCapability:
150        """Whether an approval on this deployment can become a live challenge.
151
152        ⚠️ **Ask this before reading a verdict as a deployment.** An
153        ``approved`` row is a person saying they read the code; it is not
154        a challenge in front of anybody until this answers ``can_build``.
155
156        ⚠️ **This line used to end "and today it never does".** That was
157        true until `authored_sync` landed and is not any more: with a
158        node marked ``untrusted_builds`` the answer is now ``True``, and
159        a caller that still reads it as a constant would skip the one
160        check that distinguishes a deployment which can run an approved
161        upload from one which cannot.
162
163        ⚠️ **It answers for the deployment, never for a tree.** Whether
164        *this* challenge reached a node is `push`'s task and its
165        per-node lines.
166        """
167        resp = self._http.request("GET", "/admin/authoring/build-capability")
168        _raise_for_status(resp)
169        return AuthoredBuildCapability.model_validate(resp.json())

Whether an approval on this deployment can become a live challenge.

⚠️ Ask this before reading a verdict as a deployment. An approved row is a person saying they read the code; it is not a challenge in front of anybody until this answers can_build.

⚠️ This line used to end "and today it never does". That was true until authored_sync landed and is not any more: with a node marked untrusted_builds the answer is now True, and a caller that still reads it as a constant would skip the one check that distinguishes a deployment which can run an approved upload from one which cannot.

⚠️ It answers for the deployment, never for a tree. Whether this challenge reached a node is push's task and its per-node lines.

def get( self, challenge_id: str) -> ctfy.server.models.AdminAuthoredChallengeDetail:
171    def get(self, challenge_id: str) -> AdminAuthoredChallengeDetail:
172        """One upload, with the paths and sizes of what is in it."""
173        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}")
174        _raise_for_status(resp)
175        return AdminAuthoredChallengeDetail.model_validate(resp.json())

One upload, with the paths and sizes of what is in it.

def flow(self, challenge_id: str) -> ctfy.server.models.AuthoredFlow:
177    def flow(self, challenge_id: str) -> AuthoredFlow:
178        """Where this upload is in its lifecycle, what a platform admin can do
179        next, and every transition that got it here — names included (#2147)."""
180        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/flow")
181        _raise_for_status(resp)
182        return AuthoredFlow.model_validate(resp.json())

Where this upload is in its lifecycle, what a platform admin can do next, and every transition that got it here — names included (#2147).

def precheck_runs(self, challenge_id: str) -> ctfy.server.models.PrecheckRunsInfo:
184    def precheck_runs(self, challenge_id: str) -> PrecheckRunsInfo:
185        """The AI pre-check's managed-eval runs of this upload's pending
186        versions, newest first (#3189)."""
187        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/precheck-runs")
188        _raise_for_status(resp)
189        return PrecheckRunsInfo.model_validate(resp.json())

The AI pre-check's managed-eval runs of this upload's pending versions, newest first (#3189).

def precheck_run_artifact(self, challenge_id: str, run_id: str) -> bytes:
191    def precheck_run_artifact(self, challenge_id: str, run_id: str) -> bytes:
192        """What one pre-check run's agent left behind, as ``.tar.gz`` bytes
193        (#3260). Agent-written content: read it, never serve it."""
194        resp = self._http.request(
195            "GET", f"/admin/authored-challenges/{challenge_id}/precheck-runs/{run_id}/artifact"
196        )
197        _raise_for_status(resp)
198        return bytes(resp.content)

What one pre-check run's agent left behind, as .tar.gz bytes (#3260). Agent-written content: read it, never serve it.

def history(self, challenge_id: str) -> ctfy.server.models.AuthoredHistory:
200    def history(self, challenge_id: str) -> AuthoredHistory:
201        """Everything that happened to this upload, oldest first, as one
202        stream (#3093): the transition log, every AI pre-check and every
203        injection review. Organisers and reviewers only."""
204        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/history")
205        _raise_for_status(resp)
206        return AuthoredHistory.model_validate(resp.json())

Everything that happened to this upload, oldest first, as one stream (#3093): the transition log, every AI pre-check and every injection review. Organisers and reviewers only.

def precheck_reruns( self, competition_id: str) -> ctfy.server.models.PrecheckRerunInfo:
208    def precheck_reruns(self, competition_id: str) -> PrecheckRerunInfo:
209        """The event's re-check card (#3266): the two scopes' counts and the
210        newest batch. Organisers only."""
211        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/precheck-reruns")
212        _raise_for_status(resp)
213        return PrecheckRerunInfo.model_validate(resp.json())

The event's re-check card (#3266): the two scopes' counts and the newest batch. Organisers only.

def start_precheck_reruns( self, competition_id: str, *, scope: Literal['unprechecked', 'all'] = 'unprechecked') -> ctfy.server.models.PrecheckRerunInfo:
215    def start_precheck_reruns(
216        self, competition_id: str, *, scope: RerunScope = "unprechecked"
217    ) -> PrecheckRerunInfo:
218        """Re-run the AI pre-check over the event's approved uploads in
219        *scope* (#3266). Evidence only: nothing it finds unpublishes one."""
220        resp = self._http.request(
221            "POST",
222            f"/admin/competitions/{competition_id}/precheck-reruns",
223            json={"scope": scope},
224        )
225        _raise_for_status(resp)
226        return PrecheckRerunInfo.model_validate(resp.json())

Re-run the AI pre-check over the event's approved uploads in scope (#3266). Evidence only: nothing it finds unpublishes one.

def stop_precheck_reruns( self, competition_id: str, rerun_id: str) -> ctfy.server.models.PrecheckRerunInfo:
228    def stop_precheck_reruns(self, competition_id: str, rerun_id: str) -> PrecheckRerunInfo:
229        """Stop a running re-check batch; what finished is kept (#3266)."""
230        resp = self._http.request(
231            "POST", f"/admin/competitions/{competition_id}/precheck-reruns/{rerun_id}/stop"
232        )
233        _raise_for_status(resp)
234        return PrecheckRerunInfo.model_validate(resp.json())

Stop a running re-check batch; what finished is kept (#3266).

def smoke_logs( self, challenge_id: str) -> list[ctfy.server.models.AuthoredSmokeLogSummary]:
236    def smoke_logs(self, challenge_id: str) -> builtins.list[AuthoredSmokeLogSummary]:
237        """The smoke runs kept for this upload, newest first (#2024) — the
238        same scrubbed steps its author reads. The whole set: retention, not
239        a page, bounds it."""
240        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/smoke-logs")
241        _raise_for_status(resp)
242        return [AuthoredSmokeLogSummary.model_validate(row) for row in resp.json()]

The smoke runs kept for this upload, newest first (#2024) — the same scrubbed steps its author reads. The whole set: retention, not a page, bounds it.

def versions( self, challenge_id: str) -> list[ctfy.server.models.AdminAuthoredVersionRow]:
244    def versions(self, challenge_id: str) -> builtins.list[AdminAuthoredVersionRow]:
245        """Every stored version of this upload, newest first (#2037) — the
246        rows its author reads, plus who ruled on each. The whole set."""
247        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/versions")
248        _raise_for_status(resp)
249        return [AdminAuthoredVersionRow.model_validate(row) for row in resp.json()]

Every stored version of this upload, newest first (#2037) — the rows its author reads, plus who ruled on each. The whole set.

def competitions( self, challenge_id: str) -> list[ctfy.server.models.AuthoredCompetitionRow]:
251    def competitions(self, challenge_id: str) -> builtins.list[AuthoredCompetitionRow]:
252        """Every competition an upload is curated into, with what players did
253        there (#2037) — starts and solves, counted over the whole set."""
254        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/competitions")
255        _raise_for_status(resp)
256        return [AuthoredCompetitionRow.model_validate(row) for row in resp.json()]

Every competition an upload is curated into, with what players did there (#2037) — starts and solves, counted over the whole set.

def version_diff( self, challenge_id: str, *, from_version: str, to_version: str) -> ctfy.server.models.AuthoredVersionDiff:
258    def version_diff(
259        self, challenge_id: str, *, from_version: str, to_version: str
260    ) -> AuthoredVersionDiff:
261        """Two versions of this upload compared (#2037) — the author's diff."""
262        resp = self._http.request(
263            "GET",
264            f"/admin/authored-challenges/{challenge_id}/versions/diff",
265            params={"from_version": from_version, "to_version": to_version},
266        )
267        _raise_for_status(resp)
268        return AuthoredVersionDiff.model_validate(resp.json())

Two versions of this upload compared (#2037) — the author's diff.

def version_archive(self, challenge_id: str, version_id: str) -> bytes:
270    def version_archive(self, challenge_id: str, version_id: str) -> bytes:
271        """One version as ``.tar.gz`` bytes, execute bits included (#2037)."""
272        resp = self._http.request(
273            "GET", f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/archive"
274        )
275        _raise_for_status(resp)
276        return bytes(resp.content)

One version as .tar.gz bytes, execute bits included (#2037).

def version_switches( self, challenge_id: str) -> ctfy.server.models.AuthoredVersionSwitchPlan:
278    def version_switches(self, challenge_id: str) -> AuthoredVersionSwitchPlan:
279        """Every switch of this upload's live version, newest first, and the
280        running competitions holding it (#2037)."""
281        resp = self._http.request(
282            "GET", f"/admin/authored-challenges/{challenge_id}/version-switches"
283        )
284        _raise_for_status(resp)
285        return AuthoredVersionSwitchPlan.model_validate(resp.json())

Every switch of this upload's live version, newest first, and the running competitions holding it (#2037).

def switch_version( self, challenge_id: str, version_id: str, *, when: Literal['now', 'after_competitions'] = 'now', reason: str = '', exec_surface_reviewed: bool = False, force: bool = False, force_reason: str = '') -> ctfy.server.models.AuthoredVersionSwitchInfo:
287    def switch_version(
288        self,
289        challenge_id: str,
290        version_id: str,
291        *,
292        when: AuthoredVersionSwitchWhen = "now",
293        reason: str = "",
294        exec_surface_reviewed: bool = False,
295        force: bool = False,
296        force_reason: str = "",
297    ) -> AuthoredVersionSwitchInfo:
298        """Make a version live (#2037): an approved proposal is applied, an
299        earlier approved version rolled back to (``reason`` required). ``when``
300        is ``now`` or ``after_competitions`` — applied automatically once no
301        running competition holds the challenge. Refused
302        ``authored_version_switch_refused`` with coded reasons.
303
304        ``force`` / ``force_reason`` push an immediate switch through a
305        competition that locks its challenges' versions while in play
306        (#2041): a platform administrator's act, and only with a reason."""
307        resp = self._http.request(
308            "POST",
309            f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/switch",
310            json={
311                "when": when,
312                "reason": reason,
313                "exec_surface_reviewed": exec_surface_reviewed,
314                "force": force,
315                "force_reason": force_reason,
316            },
317        )
318        _raise_for_status(resp)
319        return AuthoredVersionSwitchInfo.model_validate(resp.json())

Make a version live (#2037): an approved proposal is applied, an earlier approved version rolled back to (reason required). when is now or after_competitions — applied automatically once no running competition holds the challenge. Refused authored_version_switch_refused with coded reasons.

force / force_reason push an immediate switch through a competition that locks its challenges' versions while in play (#2041): a platform administrator's act, and only with a reason.

def cancel_switch( self, challenge_id: str, switch_id: str) -> ctfy.server.models.AuthoredVersionSwitchInfo:
321    def cancel_switch(self, challenge_id: str, switch_id: str) -> AuthoredVersionSwitchInfo:
322        """Cancel a switch still waiting for its competitions to end (#2037)."""
323        resp = self._http.request(
324            "POST",
325            f"/admin/authored-challenges/{challenge_id}/version-switches/{switch_id}/cancel",
326        )
327        _raise_for_status(resp)
328        return AuthoredVersionSwitchInfo.model_validate(resp.json())

Cancel a switch still waiting for its competitions to end (#2037).

def version_messages( self, challenge_id: str, version_id: str) -> list[ctfy.server.models.AuthoredVersionMessage]:
330    def version_messages(
331        self, challenge_id: str, version_id: str
332    ) -> builtins.list[AuthoredVersionMessage]:
333        """One version's thread between its authors and reviewers (#2037)."""
334        resp = self._http.request(
335            "GET", f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/messages"
336        )
337        _raise_for_status(resp)
338        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]

One version's thread between its authors and reviewers (#2037).

def post_version_message( self, challenge_id: str, version_id: str, body: str, *, as_review_group: bool = True) -> list[ctfy.server.models.AuthoredVersionMessage]:
340    def post_version_message(
341        self, challenge_id: str, version_id: str, body: str, *, as_review_group: bool = True
342    ) -> builtins.list[AuthoredVersionMessage]:
343        """Write to an upload's authors on one version's thread (#2037) — as
344        the review group unless ``as_review_group`` is false. The thread."""
345        resp = self._http.request(
346            "POST",
347            f"/admin/authored-challenges/{challenge_id}/versions/{version_id}/messages",
348            json={"body": body, "as_review_group": as_review_group},
349        )
350        _raise_for_status(resp)
351        return [AuthoredVersionMessage.model_validate(row) for row in resp.json()]

Write to an upload's authors on one version's thread (#2037) — as the review group unless as_review_group is false. The thread.

def version_storage(self) -> ctfy.server.models.AuthoredVersionStorageInfo:
353    def version_storage(self) -> AuthoredVersionStorageInfo:
354        """What the version store holds (#2037): ``stored_bytes`` each
355        distinct file once, ``logical_bytes`` every version stored whole."""
356        resp = self._http.request("GET", "/admin/authoring/version-storage")
357        _raise_for_status(resp)
358        return AuthoredVersionStorageInfo.model_validate(resp.json())

What the version store holds (#2037): stored_bytes each distinct file once, logical_bytes every version stored whole.

def smoke_log( self, challenge_id: str, log_id: str) -> ctfy.server.models.AuthoredSmokeLog:
360    def smoke_log(self, challenge_id: str, log_id: str) -> AuthoredSmokeLog:
361        """One smoke run of this upload, step by step (#2024)."""
362        resp = self._http.request(
363            "GET", f"/admin/authored-challenges/{challenge_id}/smoke-logs/{log_id}"
364        )
365        _raise_for_status(resp)
366        return AuthoredSmokeLog.model_validate(resp.json())

One smoke run of this upload, step by step (#2024).

def smoke_live( self, challenge_id: str) -> ctfy.server.models.AuthoredSmokeLive:
368    def smoke_live(self, challenge_id: str) -> AuthoredSmokeLive:
369        """The smoke run of this upload that is queued or running now (#2145)."""
370        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/smoke-live")
371        _raise_for_status(resp)
372        return AuthoredSmokeLive.model_validate(resp.json())

The smoke run of this upload that is queued or running now (#2145).

def cancel_smoke( self, challenge_id: str) -> ctfy.server.models.AuthoredSmokeLive:
374    def cancel_smoke(self, challenge_id: str) -> AuthoredSmokeLive:
375        """Take back this upload's smoke run while it still waits (#2145)."""
376        resp = self._http.request(
377            "POST", f"/admin/authored-challenges/{challenge_id}/smoke-live/cancel"
378        )
379        _raise_for_status(resp)
380        return AuthoredSmokeLive.model_validate(resp.json())

Take back this upload's smoke run while it still waits (#2145).

def smoke_run_lines( self, challenge_id: str, run_id: str, key: str, *, before: int = 0, after: int = 0, limit: int = 200) -> ctfy.server.models.AuthoredSmokeStepLines:
382    def smoke_run_lines(
383        self,
384        challenge_id: str,
385        run_id: str,
386        key: str,
387        *,
388        before: int = 0,
389        after: int = 0,
390        limit: int = 200,
391    ) -> AuthoredSmokeStepLines:
392        """A window of one step's full output (#2145) — the same scrubbed
393        lines its author reads; cursors as ``client.authoring.smoke_run_lines``."""
394        resp = self._http.request(
395            "GET",
396            f"/admin/authored-challenges/{challenge_id}/smoke-runs/{run_id}/lines",
397            params={"step": key, "before": before, "after": after, "limit": limit},
398        )
399        _raise_for_status(resp)
400        return AuthoredSmokeStepLines.model_validate(resp.json())

A window of one step's full output (#2145) — the same scrubbed lines its author reads; cursors as client.authoring.smoke_run_lines.

def audit( self, challenge_id: str) -> ctfy.server.models.AuthoredAuditReport:
402    def audit(self, challenge_id: str) -> AuthoredAuditReport:
403        """Run the challenge audit over this upload and report it.
404
405        ⚠️ **Evidence, never a gate.** :meth:`review` does not consult
406        this: a rule that refused an approval on a finding would make
407        the operator's judgement the audit's to overrule, and the audit
408        cannot see inside a `Dockerfile`'s `RUN` — which is the half a
409        person is reading the files for.
410
411        ⚠️ Read ``outcome`` before the counts. An empty ``findings``
412        list means *nothing was found* only when it says ``ok``;
413        ``timed_out`` and ``failed`` are the audit declining to answer,
414        and reading either as clean is the whole reason the field
415        exists.
416        """
417        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/audit")
418        _raise_for_status(resp)
419        return AuthoredAuditReport.model_validate(resp.json())

Run the challenge audit over this upload and report it.

⚠️ Evidence, never a gate. review() does not consult this: a rule that refused an approval on a finding would make the operator's judgement the audit's to overrule, and the audit cannot see inside a Dockerfile's RUN — which is the half a person is reading the files for.

⚠️ Read outcome before the counts. An empty findings list means nothing was found only when it says ok; timed_out and failed are the audit declining to answer, and reading either as clean is the whole reason the field exists.

def model_review( self, challenge_id: str) -> ctfy.server.models.AdminModelReviewInfo:
421    def model_review(self, challenge_id: str) -> AdminModelReviewInfo:
422        """The model's reading of this upload and of its proposed revision
423        (#1965 D): the injection verdict with its full reasoning and the
424        spans it quoted, the advisory findings, and near copies.
425
426        ⚠️ Read ``effective_injection`` to know what the platform acts on —
427        ``injection`` is what the model said, and an override makes it
428        ``clean``. ``unknown`` is the platform's problem (``problem`` says
429        which) and never blocks.
430        """
431        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/model-review")
432        _raise_for_status(resp)
433        return AdminModelReviewInfo.model_validate(resp.json())

The model's reading of this upload and of its proposed revision (#1965 D): the injection verdict with its full reasoning and the spans it quoted, the advisory findings, and near copies.

⚠️ Read effective_injection to know what the platform acts on — injection is what the model said, and an override makes it clean. unknown is the platform's problem (problem says which) and never blocks.

def run_model_review( self, challenge_id: str, *, revision_id: str = '') -> ctfy.server.models.AdminTaskInfo:
435    def run_model_review(self, challenge_id: str, *, revision_id: str = "") -> AdminTaskInfo:
436        """Ask the model to read this upload, or its proposed revision —
437        advice: the press decides nothing. 409 while one is in flight."""
438        params = {"revision_id": revision_id} if revision_id else None
439        resp = self._http.request(
440            "POST", f"/admin/authored-challenges/{challenge_id}/model-review", params=params
441        )
442        _raise_for_status(resp)
443        return AdminTaskInfo.model_validate(resp.json())

Ask the model to read this upload, or its proposed revision — advice: the press decides nothing. 409 while one is in flight.

def assistant_review(self, challenge_id: str) -> ctfy.server.models.AdminProbeInfo:
445    def assistant_review(self, challenge_id: str) -> AdminProbeInfo:
446        """The reviewer assistant's newest run on this upload's live tree and
447        on its proposed revision (#2830) — advice for the person reviewing,
448        never a verdict. ``enabled`` is whether this deployment names a
449        model at all."""
450        resp = self._http.request(
451            "GET", f"/admin/authored-challenges/{challenge_id}/assistant-review"
452        )
453        _raise_for_status(resp)
454        return AdminProbeInfo.model_validate(resp.json())

The reviewer assistant's newest run on this upload's live tree and on its proposed revision (#2830) — advice for the person reviewing, never a verdict. enabled is whether this deployment names a model at all.

def rerun_assistant_review(self, challenge_id: str) -> ctfy.server.models.AdminTaskInfo:
456    def rerun_assistant_review(self, challenge_id: str) -> AdminTaskInfo:
457        """Smoke this upload again with the AI pre-check, even though this
458        version was probed already (#2830, #3053). A rerun *is* a smoke, so it
459        takes :meth:`smoke`'s door (super-admin); ``409 authoring_probe_off``
460        while the event chose no model, ``authoring_probe_not_isolated`` with
461        no quarantine or dedicated node, ``authoring_probe_budget_spent``
462        once the version's cap is spent."""
463        resp = self._http.request(
464            "POST", f"/admin/authored-challenges/{challenge_id}/assistant-review"
465        )
466        _raise_for_status(resp)
467        return AdminTaskInfo.model_validate(resp.json())

Smoke this upload again with the AI pre-check, even though this version was probed already (#2830, #3053). A rerun is a smoke, so it takes smoke()'s door (super-admin); 409 authoring_probe_off while the event chose no model, authoring_probe_not_isolated with no quarantine or dedicated node, authoring_probe_budget_spent once the version's cap is spent.

def override_model_review( self, challenge_id: str, review_id: str, *, note: str) -> ctfy.server.models.AdminModelReview:
469    def override_model_review(
470        self, challenge_id: str, review_id: str, *, note: str
471    ) -> AdminModelReview:
472        """Say the text a review flagged is not an injection. ``note`` is
473        required — the audit's *why*. An upload or revision the verdict
474        rejected under automatic review is decided again."""
475        resp = self._http.request(
476            "POST",
477            f"/admin/authored-challenges/{challenge_id}/model-review/{review_id}/override",
478            json={"note": note},
479        )
480        _raise_for_status(resp)
481        return AdminModelReview.model_validate(resp.json())

Say the text a review flagged is not an injection. note is required — the audit's why. An upload or revision the verdict rejected under automatic review is decided again.

def malice_review(self, challenge_id: str) -> ctfy.server.models.AdminMaliceInfo:
483    def malice_review(self, challenge_id: str) -> AdminMaliceInfo:
484        """The malice check of this upload's live tree and of its proposed
485        revision (#3211): ``hold``, every reason, the rule hits, the model's
486        reading and quotes, and any ruling. Never calls a model."""
487        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/malice-review")
488        _raise_for_status(resp)
489        return AdminMaliceInfo.model_validate(resp.json())

The malice check of this upload's live tree and of its proposed revision (#3211): hold, every reason, the rule hits, the model's reading and quotes, and any ruling. Never calls a model.

def override_malice_review( self, challenge_id: str, review_id: str, *, decision: Literal['clear', 'confirm'], note: str) -> ctfy.server.models.AdminMaliceStanding:
491    def override_malice_review(
492        self,
493        challenge_id: str,
494        review_id: str,
495        *,
496        decision: Literal["clear", "confirm"],
497        note: str,
498    ) -> AdminMaliceStanding:
499        """Rule on a malice hold: ``clear`` releases it (a version the
500        automatic chain held is decided again), ``confirm`` keeps it.
501        ``note`` is required — the audit's *why*."""
502        resp = self._http.request(
503            "POST",
504            f"/admin/authored-challenges/{challenge_id}/malice-review/{review_id}/override",
505            json={"decision": decision, "note": note},
506        )
507        _raise_for_status(resp)
508        return AdminMaliceStanding.model_validate(resp.json())

Rule on a malice hold: clear releases it (a version the automatic chain held is decided again), confirm keeps it. note is required — the audit's why.

def read_file( self, challenge_id: str, path: str) -> ctfy.server.models.AuthoredChallengeFileBody:
510    def read_file(self, challenge_id: str, path: str) -> AuthoredChallengeFileBody:
511        """One file's contents.
512
513        ⚠️ The body is **attacker-authored text written to be run**.
514        ``binary`` and ``truncated`` say what you are not looking at;
515        treat a ``False`` on either as the only claim that you are.
516        """
517        # ⚠️ The path rides the body, never the URL (#1929): a WAF that
518        # refuses a URL ending in ``.ini`` does not read a JSON body.
519        resp = self._http.request(
520            "POST", f"/admin/authored-challenges/{challenge_id}/file", json={"path": path}
521        )
522        _raise_for_status(resp)
523        return AuthoredChallengeFileBody.model_validate(resp.json())

One file's contents.

⚠️ The body is attacker-authored text written to be run. binary and truncated say what you are not looking at; treat a False on either as the only claim that you are.

def operator_revise( self, challenge_id: str, *, archive: bytes | None = None, put: Mapping[str, bytes] | None = None, delete: list[str] | None = None, note: str = '', approve: bool = False, replace_waiting: bool = False) -> ctfy.server.models.AuthoredOperatorRevisionInfo:
525    def operator_revise(
526        self,
527        challenge_id: str,
528        *,
529        archive: bytes | None = None,
530        put: Mapping[str, bytes] | None = None,
531        delete: builtins.list[str] | None = None,
532        note: str = "",
533        approve: bool = False,
534        replace_waiting: bool = False,
535    ) -> AuthoredOperatorRevisionInfo:
536        """Submit a new version of somebody else's upload on their behalf (#3002).
537
538        Exactly one way in: ``archive`` (a whole ``.tar.gz``), or edits on top
539        of the current tree — ``put`` maps a path to its new bytes, ``delete``
540        names paths to remove. The tree meets every rule an upload's does;
541        the author stays the author, the version records you as
542        ``revised_by``, and the uploader is told which files changed.
543        ``approve`` approves it in the same press, under your name.
544        ``replace_waiting`` confirms replacing a revision the author already
545        has waiting (otherwise ``409 authored_operator_revision_replaces_proposal``).
546        """
547        data: dict[str, str] = {
548            "note": note,
549            "approve": "true" if approve else "",
550            "replace_waiting": "true" if replace_waiting else "",
551        }
552        files: dict[str, tuple[str, bytes, str]] = {}
553        if archive is not None:
554            files["file"] = ("revision.tar.gz", archive, "application/gzip")
555        else:
556            edits = [
557                {"path": path, "content_b64": base64.b64encode(body).decode()}
558                for path, body in (put or {}).items()
559            ] + [{"path": path, "content_b64": None} for path in (delete or [])]
560            data["edits"] = json.dumps(edits)
561        resp = self._http.request(
562            "POST",
563            f"/admin/authored-challenges/{challenge_id}/operator-revisions",
564            data=data,
565            files=files or None,
566        )
567        _raise_for_status(resp)
568        return AuthoredOperatorRevisionInfo.model_validate(resp.json())

Submit a new version of somebody else's upload on their behalf (#3002).

Exactly one way in: archive (a whole .tar.gz), or edits on top of the current tree — put maps a path to its new bytes, delete names paths to remove. The tree meets every rule an upload's does; the author stays the author, the version records you as revised_by, and the uploader is told which files changed. approve approves it in the same press, under your name. replace_waiting confirms replacing a revision the author already has waiting (otherwise 409 authored_operator_revision_replaces_proposal).

def graduation( self, challenge_id: str, corpus_id: str = '') -> ctfy.server.models.AuthoredGraduationInfo:
570    def graduation(self, challenge_id: str, corpus_id: str = "") -> AuthoredGraduationInfo:
571        """Whether an upload may graduate into the corpus, and as what (plan P3.3).
572
573        ``refusals`` names every reason it may not yet — empty when it may.
574        ``corpus_id`` is the id it would take: the one passed, or else the
575        next ``COMMUNITY-NNN`` this deployment's catalog does not hold. The
576        pull request's title and body are what a person opens it with.
577        """
578        params = {"corpus_id": corpus_id} if corpus_id else None
579        resp = self._http.request(
580            "GET", f"/admin/authored-challenges/{challenge_id}/graduation", params=params
581        )
582        _raise_for_status(resp)
583        return AuthoredGraduationInfo.model_validate(resp.json())

Whether an upload may graduate into the corpus, and as what (plan P3.3).

refusals names every reason it may not yet — empty when it may. corpus_id is the id it would take: the one passed, or else the next COMMUNITY-NNN this deployment's catalog does not hold. The pull request's title and body are what a person opens it with.

def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes:
585    def graduation_bundle(self, challenge_id: str, corpus_id: str) -> bytes:
586        """The ``.tar.gz`` to unpack at the corpus repository's root.
587
588        It holds ``COMMUNITY/<corpus_id>/``: the tree as reviewed, the
589        ``LICENSE`` its author declared and a ``CREDITS.md``. Nothing is
590        recorded on the platform — opening the pull request is a person's
591        act, in another repository.
592        """
593        resp = self._http.request(
594            "GET",
595            f"/admin/authored-challenges/{challenge_id}/graduation/bundle",
596            params={"corpus_id": corpus_id},
597        )
598        _raise_for_status(resp)
599        return resp.content

The .tar.gz to unpack at the corpus repository's root.

It holds COMMUNITY/<corpus_id>/: the tree as reviewed, the LICENSE its author declared and a CREDITS.md. Nothing is recorded on the platform — opening the pull request is a person's act, in another repository.

def review( self, challenge_id: str, *, status: Literal['pending', 'approved', 'rejected'], note: str = '', exec_surface_reviewed: bool = False) -> ctfy.server.models.AdminAuthoredChallengeRow:
601    def review(
602        self,
603        challenge_id: str,
604        *,
605        status: ReviewStatus,
606        note: str = "",
607        exec_surface_reviewed: bool = False,
608    ) -> AdminAuthoredChallengeRow:
609        """Record a verdict. ``rejected`` requires a note.
610
611        ``pending`` withdraws a verdict and clears its trail — a
612        mis-clicked approval is undoable without deleting the upload.
613        ``exec_surface_reviewed`` (#3337) confirms, with an approval, that
614        you read what the tree runs; with your verdict that is a person's
615        release, which replaces an automatic review's policy release.
616        """
617        resp = self._http.request(
618            "POST",
619            f"/admin/authored-challenges/{challenge_id}/review",
620            json={"status": status, "note": note},
621            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
622        )
623        _raise_for_status(resp)
624        return AdminAuthoredChallengeRow.model_validate(resp.json())

Record a verdict. rejected requires a note.

pending withdraws a verdict and clears its trail — a mis-clicked approval is undoable without deleting the upload. exec_surface_reviewed (#3337) confirms, with an approval, that you read what the tree runs; with your verdict that is a person's release, which replaces an automatic review's policy release.

def push(self, challenge_id: str) -> ctfy.server.models.AdminTaskInfo:
626    def push(self, challenge_id: str) -> AdminTaskInfo:
627        """Send an approved upload to every node marked for untrusted builds.
628
629        ⚠️ **The verb after `publish`, and they are not the same act.**
630        `publish` writes the tree into *this deployment's* catalog root;
631        a multi-host node's catalog is a different directory on a
632        different machine, and until this runs the node answers 404 for
633        the id the platform now lists.
634
635        Returns the task: the push is per node, and which node refused
636        is on its log rather than in one summary line.
637        """
638        resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/push")
639        _raise_for_status(resp)
640        return AdminTaskInfo.model_validate(resp.json())

Send an approved upload to every node marked for untrusted builds.

⚠️ The verb after publish, and they are not the same act. publish writes the tree into this deployment's catalog root; a multi-host node's catalog is a different directory on a different machine, and until this runs the node answers 404 for the id the platform now lists.

Returns the task: the push is per node, and which node refused is on its log rather than in one summary line.

def smoke( self, challenge_id: str, *, build_network: Optional[Literal['online', 'offline']] = None) -> ctfy.server.models.AdminTaskInfo:
642    def smoke(
643        self, challenge_id: str, *, build_network: BuildNetwork | None = None
644    ) -> AdminTaskInfo:
645        """Run this upload's own reference exploit against it, on a node.
646
647        ⚠️ **The machine check an approval cannot make.** The audit is
648        static and a reviewer reads files; only a run says whether the
649        estate comes up and the exploit captures what it claims.
650
651        Returns the task — the verdict is on its log, and it is
652        deliberately four-valued: *solved*, *unsolved*, *estate_failed*,
653        *malformed*, plus *errored* for a node that could not tell.
654
655        #2873: ``build_network`` (``online`` / ``offline``) is whether this
656        run's build ``RUN`` steps reach the network; ``None`` is the upload's
657        competition's setting.
658        """
659        resp = self._http.request(
660            "POST",
661            f"/admin/authored-challenges/{challenge_id}/smoke",
662            params={"build_network": build_network} if build_network else None,
663        )
664        _raise_for_status(resp)
665        return AdminTaskInfo.model_validate(resp.json())

Run this upload's own reference exploit against it, on a node.

⚠️ The machine check an approval cannot make. The audit is static and a reviewer reads files; only a run says whether the estate comes up and the exploit captures what it claims.

Returns the task — the verdict is on its log, and it is deliberately four-valued: solved, unsolved, estate_failed, malformed, plus errored for a node that could not tell.

2873: build_network (online / offline) is whether this

run's build RUN steps reach the network; None is the upload's competition's setting.

def resmoke_platform_faults( self, *, competition_id: str = '', dry_run: bool = False) -> ctfy.server.models.AdminTaskInfo:
667    def resmoke_platform_faults(
668        self, *, competition_id: str = "", dry_run: bool = False
669    ) -> AdminTaskInfo:
670        """Re-smoke every upload #2029 mislabelled, once (the operator one-shot).
671
672        A dropped execute bit and a run-time ``pip install`` on a no-egress
673        smoke network recorded the author's ``unsolved`` / ``estate_failed``;
674        both are fixed. This enqueues the backfill, which selects those
675        uploads, re-runs each uncharged, and mails each author once that the
676        earlier failure was the platform's. ``dry_run`` reports the selection
677        and runs nothing. Returns the task.
678        """
679        resp = self._http.request(
680            "POST",
681            "/admin/authored-challenges/resmoke-platform-faults",
682            params={"competition_id": competition_id, "dry_run": dry_run},
683        )
684        _raise_for_status(resp)
685        return AdminTaskInfo.model_validate(resp.json())

Re-smoke every upload #2029 mislabelled, once (the operator one-shot).

A dropped execute bit and a run-time pip install on a no-egress smoke network recorded the author's unsolved / estate_failed; both are fixed. This enqueues the backfill, which selects those uploads, re-runs each uncharged, and mails each author once that the earlier failure was the platform's. dry_run reports the selection and runs nothing. Returns the task.

def strays(self) -> list[ctfy.server.models.AuthoredStrayRow]:
687    def strays(self) -> builtins.list[AuthoredStrayRow]:
688        """Every stored challenge id a competition may not hold (#2553): an
689        invitation-only event's upload in another event's list or map — what
690        :meth:`strip_strays` removes. Read-only; empty is clean. (Before an
691        upgrade the check is offline: ``ctfy-admin admin authored strays --db``.)"""
692        resp = self._http.request("GET", "/admin/authored-challenges/strays")
693        _raise_for_status(resp)
694        return [AuthoredStrayRow.model_validate(row) for row in resp.json()]

Every stored challenge id a competition may not hold (#2553): an invitation-only event's upload in another event's list or map — what strip_strays() removes. Read-only; empty is clean. (Before an upgrade the check is offline: ctfy-admin admin authored strays --db.)

def strip_strays( self, *, apply: bool = False) -> ctfy.server.models.AuthoredStripResult:
696    def strip_strays(self, *, apply: bool = False) -> AuthoredStripResult:
697        """Remove every stray (#2553) — a dry run unless ``apply``. Each
698        removal is recorded with the platform as actor and keeps its solves'
699        points; the boxes it left behind are reclaimed."""
700        resp = self._http.request(
701            "POST",
702            "/admin/authored-challenges/strays/strip",
703            params={"apply": "true" if apply else "false"},
704        )
705        _raise_for_status(resp)
706        return AuthoredStripResult.model_validate(resp.json())

Remove every stray (#2553) — a dry run unless apply. Each removal is recorded with the platform as actor and keeps its solves' points; the boxes it left behind are reclaimed.

def publish( self, challenge_id: str, *, exec_surface_reviewed: bool = False) -> ctfy.server.models.AdminAuthoredChallengeRow:
708    def publish(
709        self, challenge_id: str, *, exec_surface_reviewed: bool = False
710    ) -> AdminAuthoredChallengeRow:
711        """Write an approved upload into the deployment's catalog.
712
713        A separate verb from :meth:`review` on purpose: approving is a
714        person's verdict on somebody else's code, and it must not fail
715        because a corpus commit landed on the id in the days since. The
716        409 this raises carries every reason at once — the id is taken,
717        the row is not approved, the tree has no ``metadata.yaml`` — so a
718        caller fixes them in one pass rather than one press at a time.
719
720        ``exec_surface_reviewed`` confirms you read the files the tree
721        runs on a node or hands players to run (``executes`` on
722        :meth:`get`'s file list, plan P2.3). A tree with such files is
723        refused without it — once; the stamp then records who read it.
724
725        The row that comes back is **read back from the store**, so its
726        ``in_catalog`` is a fact about the disk after the write.
727        """
728        resp = self._http.request(
729            "POST",
730            f"/admin/authored-challenges/{challenge_id}/publish",
731            # Only what was said: absent is the server's own default.
732            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
733        )
734        _raise_for_status(resp)
735        return AdminAuthoredChallengeRow.model_validate(resp.json())

Write an approved upload into the deployment's catalog.

A separate verb from review() on purpose: approving is a person's verdict on somebody else's code, and it must not fail because a corpus commit landed on the id in the days since. The 409 this raises carries every reason at once — the id is taken, the row is not approved, the tree has no metadata.yaml — so a caller fixes them in one pass rather than one press at a time.

exec_surface_reviewed confirms you read the files the tree runs on a node or hands players to run (executes on get()'s file list, plan P2.3). A tree with such files is refused without it — once; the stamp then records who read it.

The row that comes back is read back from the store, so its in_catalog is a fact about the disk after the write.

def release_to_catalog( self, challenge_id: str, *, exec_surface_reviewed: bool = False) -> ctfy.server.models.AdminAuthoredChallengeRow:
737    def release_to_catalog(
738        self, challenge_id: str, *, exec_surface_reviewed: bool = False
739    ) -> AdminAuthoredChallengeRow:
740        """Put a published upload in the **public catalog** (#1989).
741
742        ⚠️ **Publishing is not this.** A published upload is seen only in
743        the competitions that curate it — by their players once each
744        starts, their organisers, its authors and platform admins. This is
745        the separate step that lists it to everybody; who released it and
746        when lands on the row (``catalog_released_by`` /
747        ``catalog_released_at``), and a second call changes nothing.
748
749        A tree an automatic review released is refused (409, with reasons)
750        until a person has recorded a content verdict on it (:meth:`review`)
751        and a platform admin has read what it runs — ``exec_surface_reviewed``
752        confirms the second on this call.
753        """
754        resp = self._http.request(
755            "POST",
756            f"/admin/authored-challenges/{challenge_id}/catalog-release",
757            # Only what was said: absent is the server's own default.
758            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
759        )
760        _raise_for_status(resp)
761        return AdminAuthoredChallengeRow.model_validate(resp.json())

Put a published upload in the public catalog (#1989).

⚠️ Publishing is not this. A published upload is seen only in the competitions that curate it — by their players once each starts, their organisers, its authors and platform admins. This is the separate step that lists it to everybody; who released it and when lands on the row (catalog_released_by / catalog_released_at), and a second call changes nothing.

A tree an automatic review released is refused (409, with reasons) until a person has recorded a content verdict on it (review()) and a platform admin has read what it runs — exec_surface_reviewed confirms the second on this call.

def retire( self, challenge_id: str, *, scores: Optional[Literal['kept', 'removed']] = None) -> ctfy.server.models.AdminAuthoredChallengeRow:
763    def retire(
764        self, challenge_id: str, *, scores: AuthoredScoresChoice | None = None
765    ) -> AdminAuthoredChallengeRow:
766        """Stop offering an upload; its history is kept (#2760).
767
768        Platform admins only. It leaves every competition and is no longer
769        open to everyone; running environments run out on their own. A call
770        on an upload already retired changes nothing.
771
772        ``scores`` (#2991) decides the points already scored on it: ``kept``
773        (and ``None``, the server's default) or ``removed`` — see
774        :meth:`points_at_stake` for what each would touch.
775        """
776        resp = self._http.request(
777            "POST",
778            f"/admin/authored-challenges/{challenge_id}/retire",
779            params={"scores": scores} if scores else None,
780        )
781        _raise_for_status(resp)
782        return AdminAuthoredChallengeRow.model_validate(resp.json())

Stop offering an upload; its history is kept (#2760).

Platform admins only. It leaves every competition and is no longer open to everyone; running environments run out on their own. A call on an upload already retired changes nothing.

scores (#2991) decides the points already scored on it: kept (and None, the server's default) or removed — see points_at_stake() for what each would touch.

def unretire( self, challenge_id: str) -> ctfy.server.models.AdminAuthoredChallengeRow:
784    def unretire(self, challenge_id: str) -> AdminAuthoredChallengeRow:
785        """Offer a retired upload again (#2760): back to approved with no
786        competition using it. A call on one that is not retired changes
787        nothing."""
788        resp = self._http.request("POST", f"/admin/authored-challenges/{challenge_id}/unretire")
789        _raise_for_status(resp)
790        return AdminAuthoredChallengeRow.model_validate(resp.json())

Offer a retired upload again (#2760): back to approved with no competition using it. A call on one that is not retired changes nothing.

def other_uploads( self, challenge_id: str) -> ctfy.server.models.AuthoredOtherUploads:
792    def other_uploads(self, challenge_id: str) -> AuthoredOtherUploads:
793        """The other uploads of this upload's challenge (#3110): same
794        competition, same title, same team or author — and the one approved
795        and smoked clean, suggested as the one to keep."""
796        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/other-uploads")
797        _raise_for_status(resp)
798        return AuthoredOtherUploads.model_validate(resp.json())

The other uploads of this upload's challenge (#3110): same competition, same title, same team or author — and the one approved and smoked clean, suggested as the one to keep.

def merge( self, challenge_id: str, *, into: str, reason: str, confirm: bool = False) -> ctfy.server.models.AdminAuthoredChallengeDetail:
800    def merge(
801        self, challenge_id: str, *, into: str, reason: str, confirm: bool = False
802    ) -> AdminAuthoredChallengeDetail:
803        """Fold an upload into another upload of the same challenge (#3110):
804        recorded as ``merged`` with ``reason``, and taken out of every
805        competition's set (points scored there kept). Platform admins only.
806        Refused while anything is in play; ``confirm`` lifts only a running
807        event that would lose the challenge."""
808        params: dict[str, str] = {"into": into, "reason": reason}
809        if confirm:
810            params["confirm"] = "true"
811        resp = self._http.request(
812            "POST", f"/admin/authored-challenges/{challenge_id}/merge", params=params
813        )
814        _raise_for_status(resp)
815        return AdminAuthoredChallengeDetail.model_validate(resp.json())

Fold an upload into another upload of the same challenge (#3110): recorded as merged with reason, and taken out of every competition's set (points scored there kept). Platform admins only. Refused while anything is in play; confirm lifts only a running event that would lose the challenge.

def unmerge( self, challenge_id: str, *, reason: str = '') -> ctfy.server.models.AdminAuthoredChallengeDetail:
817    def unmerge(self, challenge_id: str, *, reason: str = "") -> AdminAuthoredChallengeDetail:
818        """Undo a merge (#3110): the upload is its own challenge again; no
819        competition comes back by itself."""
820        resp = self._http.request(
821            "POST",
822            f"/admin/authored-challenges/{challenge_id}/unmerge",
823            params={"reason": reason} if reason else None,
824        )
825        _raise_for_status(resp)
826        return AdminAuthoredChallengeDetail.model_validate(resp.json())

Undo a merge (#3110): the upload is its own challenge again; no competition comes back by itself.

def unrelease_from_catalog( self, challenge_id: str) -> ctfy.server.models.AdminAuthoredChallengeRow:
828    def unrelease_from_catalog(self, challenge_id: str) -> AdminAuthoredChallengeRow:
829        """Withdraw an upload's public-catalog release (#2759): no longer
830        open to everyone, only the competitions using it.
831
832        Platform admins only. Every competition use is kept — an upload no
833        competition uses is then seen by nobody — and environments running
834        outside a competition run out on their own. Recorded as an
835        ``unreleased`` lifecycle record; a call on an upload that is not
836        open to everyone changes nothing.
837        """
838        resp = self._http.request(
839            "POST", f"/admin/authored-challenges/{challenge_id}/catalog-unrelease"
840        )
841        _raise_for_status(resp)
842        return AdminAuthoredChallengeRow.model_validate(resp.json())

Withdraw an upload's public-catalog release (#2759): no longer open to everyone, only the competitions using it.

Platform admins only. Every competition use is kept — an upload no competition uses is then seen by nobody — and environments running outside a competition run out on their own. Recorded as an unreleased lifecycle record; a call on an upload that is not open to everyone changes nothing.

def withdraw_policy_release( self, challenge_id: str) -> ctfy.server.models.AdminAuthoredChallengeRow:
844    def withdraw_policy_release(self, challenge_id: str) -> AdminAuthoredChallengeRow:
845        """Withdraw the policy release an automatic review wrote (#3337):
846        the tree is no longer held to its event's dedicated pool, and starts
847        nowhere until a person approves it with ``exec_surface_reviewed``
848        (:meth:`review`). Platform admins only; recorded as
849        ``policy_unreleased``; a call with no release changes nothing.
850        """
851        resp = self._http.request(
852            "POST", f"/admin/authored-challenges/{challenge_id}/policy-unrelease"
853        )
854        _raise_for_status(resp)
855        return AdminAuthoredChallengeRow.model_validate(resp.json())

Withdraw the policy release an automatic review wrote (#3337): the tree is no longer held to its event's dedicated pool, and starts nowhere until a person approves it with exec_surface_reviewed (review()). Platform admins only; recorded as policy_unreleased; a call with no release changes nothing.

def points_at_stake( self, challenge_id: str, action: Literal['start_smoke', 'smoke_passes', 'smoke_fails', 'smoke_errors', 'tree_refused', 'smoke_retries', 'recheck_fails', 'recheck_passes', 'reupload', 'resmoke', 'approve', 'reject', 'reopen_review', 'publish', 'unpublish', 'republish', 'catalog_release', 'catalog_unrelease', 'retire', 'unretire', 'delete', 'curate', 'uncurate', 'submit_revision', 'withdraw_revision', 'approve_revision', 'reject_revision', 'apply_revision'], competition_id: str = '') -> ctfy.server.models.AuthoredPointsAtStake:
857    def points_at_stake(
858        self, challenge_id: str, action: AuthoredFlowAction, competition_id: str = ""
859    ) -> AuthoredPointsAtStake:
860        """What removing an upload would decide about the points already
861        scored on it (#2991): for ``uncurate`` (in ``competition_id``, or its
862        own), ``retire`` or ``unpublish``, the ranked solves and teams at
863        stake and the effects of keeping and of removing them."""
864        params: dict[str, str] = {"action": action}
865        if competition_id:
866            params["competition_id"] = competition_id
867        resp = self._http.request(
868            "GET", f"/admin/authored-challenges/{challenge_id}/points", params=params
869        )
870        _raise_for_status(resp)
871        return AuthoredPointsAtStake.model_validate(resp.json())

What removing an upload would decide about the points already scored on it (#2991): for uncurate (in competition_id, or its own), retire or unpublish, the ranked solves and teams at stake and the effects of keeping and of removing them.

def unpublish( self, challenge_id: str, *, reason: str, scores: Optional[Literal['kept', 'removed']] = None) -> ctfy.server.models.AdminAuthoredChallengeRow:
873    def unpublish(
874        self,
875        challenge_id: str,
876        *,
877        reason: str,
878        scores: AuthoredScoresChoice | None = None,
879    ) -> AdminAuthoredChallengeRow:
880        """Take a published challenge back out of the deployment's catalog.
881
882        ``reason`` is required (``422`` with ``unpublish_needs_reason`` when
883        blank): the take-down is recorded — who, when, why — on the upload's
884        publication history (``AdminAuthoredChallengeDetail.lifecycle``,
885        #1965 H), which the authoring board reads.
886
887        :meth:`publish` had no inverse, so an upload that turned out to
888        leak its own answer stayed in front of every entrant and the only
889        recourse was a shell on the platform host.
890
891        It deletes the *publication*, never the row: the author's tree,
892        the reviewer's verdict and the smoke result all survive, and
893        publishing again is the same call it always was. The boxes already
894        running on it are stopped too (the route's ``stop_instances``
895        defaults on and this call does not send it), so nobody new can
896        launch and nobody keeps playing it. It also leaves every competition
897        that uses it (#2740 D7); allowing it again adds it back to none.
898
899        Raises 409 when the deployment's catalog does not hold the id,
900        which is the mirror of publishing something already published.
901
902        ``scores`` (#2991): ``removed`` takes the points already scored on it
903        off the board; ``kept`` and ``None`` keep them.
904        """
905        body: dict[str, str] = {"reason": reason}
906        if scores:
907            body["scores"] = scores
908        resp = self._http.request(
909            "POST",
910            f"/admin/authored-challenges/{challenge_id}/unpublish",
911            json=body,
912        )
913        _raise_for_status(resp)
914        return AdminAuthoredChallengeRow.model_validate(resp.json())

Take a published challenge back out of the deployment's catalog.

reason is required (422 with unpublish_needs_reason when blank): the take-down is recorded — who, when, why — on the upload's publication history (AdminAuthoredChallengeDetail.lifecycle,

1965 H), which the authoring board reads.

publish() had no inverse, so an upload that turned out to leak its own answer stayed in front of every entrant and the only recourse was a shell on the platform host.

It deletes the publication, never the row: the author's tree, the reviewer's verdict and the smoke result all survive, and publishing again is the same call it always was. The boxes already running on it are stopped too (the route's stop_instances defaults on and this call does not send it), so nobody new can launch and nobody keeps playing it. It also leaves every competition that uses it (#2740 D7); allowing it again adds it back to none.

Raises 409 when the deployment's catalog does not hold the id, which is the mirror of publishing something already published.

scores (#2991): removed takes the points already scored on it off the board; kept and None keep them.

def curate( self, challenge_id: str, competition_id: str = '', *, reason: str = '') -> ctfy.server.models.AdminAuthoredCurationRow:
916    def curate(
917        self, challenge_id: str, competition_id: str = "", *, reason: str = ""
918    ) -> AdminAuthoredCurationRow:
919        """Put a published upload in front of this competition's entrants.
920
921        ⚠️ **The step after `publish`, and the two are not one act.**
922        Publishing writes the tree into this deployment's catalog;
923        curating adds its id to *one competition's* challenge set. Until
924        this existed the only way to take it was the competition edit
925        form, which re-sends the whole list — so two organisers working a
926        review queue dropped each other's additions. This appends.
927
928        Refused (409, with reasons) for a competition whose set comes
929        from a catalog bucket, and for an id this deployment has not
930        published: the second would put a challenge in front of entrants
931        that no node can start.
932
933        ``competition_id`` names another competition to curate it into —
934        the workshop → arena step — and every refusal is then asked of
935        that one. Empty is the upload's own competition.
936
937        ``warnings`` on the answer names what the target would refuse at its
938        own upload door and this verb does not (#1965): a static answer
939        curated into a competition reviewed automatically.
940
941        ``reason`` confirms the change for a competition that locks its
942        challenges while in play (#2041) — refused
943        (``curation_locked_needs_reason``) without one, and recorded with it.
944        """
945        resp = self._http.request(
946            "POST",
947            f"/admin/authored-challenges/{challenge_id}/curation",
948            params=_curation_params(competition_id, reason),
949        )
950        _raise_for_status(resp)
951        return AdminAuthoredCurationRow.model_validate(resp.json())

Put a published upload in front of this competition's entrants.

⚠️ The step after publish, and the two are not one act. Publishing writes the tree into this deployment's catalog; curating adds its id to one competition's challenge set. Until this existed the only way to take it was the competition edit form, which re-sends the whole list — so two organisers working a review queue dropped each other's additions. This appends.

Refused (409, with reasons) for a competition whose set comes from a catalog bucket, and for an id this deployment has not published: the second would put a challenge in front of entrants that no node can start.

competition_id names another competition to curate it into — the workshop → arena step — and every refusal is then asked of that one. Empty is the upload's own competition.

warnings on the answer names what the target would refuse at its own upload door and this verb does not (#1965): a static answer curated into a competition reviewed automatically.

reason confirms the change for a competition that locks its challenges while in play (#2041) — refused (curation_locked_needs_reason) without one, and recorded with it.

def uncurate( self, challenge_id: str, competition_id: str = '', *, reason: str = '', scores: Optional[Literal['kept', 'removed']] = None) -> ctfy.server.models.AdminAuthoredCurationRow:
953    def uncurate(
954        self,
955        challenge_id: str,
956        competition_id: str = "",
957        *,
958        reason: str = "",
959        scores: AuthoredScoresChoice | None = None,
960    ) -> AdminAuthoredCurationRow:
961        """Take an upload back out of this competition's challenge set.
962
963        Carries neither of :meth:`curate`'s refusals: removing an id from
964        a set that should not hold it is the repair, so gating it on the
965        same conditions would keep the broken state. ``competition_id``
966        names which competition; empty is the upload's own. ``reason`` is
967        :meth:`curate`'s, for a locked competition in play (#2041).
968
969        ``scores`` (#2991): ``kept`` keeps the points already scored there;
970        ``removed`` and ``None`` (the removal as it always was) do not.
971        """
972        params = _curation_params(competition_id, reason) or {}
973        if scores:
974            params["scores"] = scores
975        resp = self._http.request(
976            "DELETE",
977            f"/admin/authored-challenges/{challenge_id}/curation",
978            params=params or None,
979        )
980        _raise_for_status(resp)
981        return AdminAuthoredCurationRow.model_validate(resp.json())

Take an upload back out of this competition's challenge set.

Carries neither of curate()'s refusals: removing an id from a set that should not hold it is the repair, so gating it on the same conditions would keep the broken state. competition_id names which competition; empty is the upload's own. reason is curate()'s, for a locked competition in play (#2041).

scores (#2991): kept keeps the points already scored there; removed and None (the removal as it always was) do not.

def bulk_curate( self, competition_id: str, target_competition_ids: list[str], *, challenge_ids: list[str] | None = None, lock_versions: bool = True, dry_run: bool = False, reason: str = '') -> ctfy.server.models.BulkCurationResponse:
 983    def bulk_curate(
 984        self,
 985        competition_id: str,
 986        target_competition_ids: builtins.list[str],
 987        *,
 988        challenge_ids: builtins.list[str] | None = None,
 989        lock_versions: bool = True,
 990        dry_run: bool = False,
 991        reason: str = "",
 992    ) -> BulkCurationResponse:
 993        """Add ``competition_id``'s approved uploads to each target (#2120).
 994
 995        Each (upload, target) pair is :meth:`curate`'s press, with its
 996        refusals, and gets one ``outcome``; ``challenge_ids`` narrows the
 997        uploads. ``lock_versions`` turns each target's version lock on after.
 998        ``reason`` confirms changes to a target locked in play (#2041).
 999        ``dry_run=True`` is the preview.
1000        """
1001        resp = self._http.request(
1002            "POST",
1003            f"/admin/competitions/{competition_id}/authored-challenges/bulk-curation",
1004            json={
1005                "target_competition_ids": list(target_competition_ids),
1006                "challenge_ids": challenge_ids,
1007                "lock_versions": lock_versions,
1008                "dry_run": dry_run,
1009                "confirm": bool(reason),
1010                "reason": reason,
1011            },
1012        )
1013        _raise_for_status(resp)
1014        return BulkCurationResponse.model_validate(resp.json())

Add competition_id's approved uploads to each target (#2120).

Each (upload, target) pair is curate()'s press, with its refusals, and gets one outcome; challenge_ids narrows the uploads. lock_versions turns each target's version lock on after. reason confirms changes to a target locked in play (#2041). dry_run=True is the preview.

def revisions( self, challenge_id: str) -> list[ctfy.server.models.AdminAuthoredRevisionRow]:
1016    def revisions(self, challenge_id: str) -> builtins.list[AdminAuthoredRevisionRow]:
1017        """Every revision of one upload, newest first — the proposal, if
1018        any, and the history of those applied (plan P3.1)."""
1019        resp = self._http.request("GET", f"/admin/authored-challenges/{challenge_id}/revisions")
1020        _raise_for_status(resp)
1021        return [AdminAuthoredRevisionRow.model_validate(row) for row in resp.json()]

Every revision of one upload, newest first — the proposal, if any, and the history of those applied (plan P3.1).

def revision_diff( self, challenge_id: str, revision_id: str) -> ctfy.server.models.AuthoredRevisionDiff:
1023    def revision_diff(self, challenge_id: str, revision_id: str) -> AuthoredRevisionDiff:
1024        """A proposed revision against the live tree — what a reviewer reads.
1025
1026        ⚠️ A changed file with an empty ``diff`` says why in ``omitted``
1027        (``binary``, ``too_large``, ``over_budget``); read such a file
1028        whole with :meth:`read_revision_file`. ``needs_exec_review`` says
1029        whether :meth:`apply_revision` will ask you to confirm the
1030        execution-surface review.
1031        """
1032        resp = self._http.request(
1033            "GET", f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/diff"
1034        )
1035        _raise_for_status(resp)
1036        return AuthoredRevisionDiff.model_validate(resp.json())

A proposed revision against the live tree — what a reviewer reads.

⚠️ A changed file with an empty diff says why in omitted (binary, too_large, over_budget); read such a file whole with read_revision_file(). needs_exec_review says whether apply_revision() will ask you to confirm the execution-surface review.

def read_revision_file( self, challenge_id: str, revision_id: str, path: str) -> ctfy.server.models.AuthoredChallengeFileBody:
1038    def read_revision_file(
1039        self, challenge_id: str, revision_id: str, path: str
1040    ) -> AuthoredChallengeFileBody:
1041        """One file of a revision, whole — attacker-authored text written
1042        to be run, like :meth:`read_file`'s."""
1043        resp = self._http.request(
1044            "POST",
1045            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/file",
1046            json={"path": path},
1047        )
1048        _raise_for_status(resp)
1049        return AuthoredChallengeFileBody.model_validate(resp.json())

One file of a revision, whole — attacker-authored text written to be run, like read_file()'s.

def review_revision( self, challenge_id: str, revision_id: str, *, status: Literal['pending', 'approved', 'rejected'], note: str = '') -> ctfy.server.models.AdminAuthoredRevisionRow:
1051    def review_revision(
1052        self, challenge_id: str, revision_id: str, *, status: ReviewStatus, note: str = ""
1053    ) -> AdminAuthoredRevisionRow:
1054        """Record a verdict on a proposed revision. ``rejected`` requires a
1055        note and ``pending`` withdraws a verdict, as for an upload. An
1056        applied revision is history and is refused (409)."""
1057        resp = self._http.request(
1058            "POST",
1059            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/review",
1060            json={"status": status, "note": note},
1061        )
1062        _raise_for_status(resp)
1063        return AdminAuthoredRevisionRow.model_validate(resp.json())

Record a verdict on a proposed revision. rejected requires a note and pending withdraws a verdict, as for an upload. An applied revision is history and is refused (409).

def apply_revision( self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False) -> ctfy.server.models.AdminAuthoredChallengeRow:
1065    def apply_revision(
1066        self, challenge_id: str, revision_id: str, *, exec_surface_reviewed: bool = False
1067    ) -> AdminAuthoredChallengeRow:
1068        """Make an approved revision the upload's live version (plan P3.1).
1069
1070        A published upload's directory is replaced whole — boxes already
1071        running keep the bytes they started with — and the catalog is
1072        reloaded. Nodes that hold the upload keep the previous tree until
1073        :meth:`push` sends the new one.
1074
1075        ``exec_surface_reviewed`` confirms you read the revision's files
1076        marked ``executes``: a published upload whose revision has any is
1077        refused without it, every time — the bytes are new. The 409 carries
1078        every reason at once.
1079        """
1080        resp = self._http.request(
1081            "POST",
1082            f"/admin/authored-challenges/{challenge_id}/revisions/{revision_id}/apply",
1083            params={"exec_surface_reviewed": "true"} if exec_surface_reviewed else None,
1084        )
1085        _raise_for_status(resp)
1086        return AdminAuthoredChallengeRow.model_validate(resp.json())

Make an approved revision the upload's live version (plan P3.1).

A published upload's directory is replaced whole — boxes already running keep the bytes they started with — and the catalog is reloaded. Nodes that hold the upload keep the previous tree until push() sends the new one.

exec_surface_reviewed confirms you read the revision's files marked executes: a published upload whose revision has any is refused without it, every time — the bytes are new. The 409 carries every reason at once.