ctfy.sdk.resources.authoring

client.authoring — uploading a challenge you wrote.

For the competition format where entrants set challenges for each other. The upload is a .tar.gz of one benchmark directory, exactly the shape tar czf mine.tgz mychallenge/ produces — the wrapping directory is stripped server-side, so an author never has to know whether to include it.

⚠️ The server records the upload pending and does not judge it: a verdict means running the audit and a smoke test on a worker, which takes minutes. review_status on the returned row is what to poll.

  1"""``client.authoring`` — uploading a challenge you wrote.
  2
  3For the competition format where entrants set challenges for each other.
  4The upload is a ``.tar.gz`` of one benchmark directory, exactly the
  5shape ``tar czf mine.tgz mychallenge/`` produces — the wrapping
  6directory is stripped server-side, so an author never has to know
  7whether to include it.
  8
  9⚠️ The server records the upload ``pending`` and does not judge it: a
 10verdict means running the audit and a smoke test on a worker, which
 11takes minutes. ``review_status`` on the returned row is what to poll.
 12"""
 13
 14from __future__ import annotations
 15
 16from ctfy.challenge.benchmark import ChallengeCategory
 17from ctfy.challenge.scaffold import Stack
 18from ctfy.core.enums import (
 19    AuthoredAiAssistanceState,
 20    AuthoredCredit,
 21    AuthoredDestination,
 22    AuthoredLicenseState,
 23    AuthoredProvenanceState,
 24)
 25from ctfy.sdk._helpers import _raise_for_status
 26from ctfy.sdk.base import BaseHttpClient
 27from ctfy.server.models import (
 28    AuthoredAuditReport,
 29    AuthoredChallengeInfo,
 30    AuthoredChallengeRow,
 31    AuthoredRevisionInfo,
 32    AuthoringReceptionInfo,
 33    AuthoringTemplateManifest,
 34)
 35
 36
 37class AuthoringResource:
 38    """Upload and list the calling team's authored challenges."""
 39
 40    def __init__(self, http: BaseHttpClient) -> None:
 41        self._http = http
 42
 43    def upload(
 44        self,
 45        competition_id: str,
 46        *,
 47        slug: str,
 48        archive: bytes,
 49        filename: str = "challenge.tar.gz",
 50        license: AuthoredLicenseState = "",
 51        provenance: AuthoredProvenanceState = "",
 52        source_url: str = "",
 53        ai_assisted: AuthoredAiAssistanceState = "",
 54        eval_use: bool = False,
 55        credit: AuthoredCredit = "anonymous",
 56        accepted_terms_version: str = "",
 57        destination: AuthoredDestination = "public",
 58    ) -> AuthoredChallengeInfo:
 59        """Upload one challenge tree.
 60
 61        ``slug`` is your own label for the upload, never its id: the
 62        platform mints the benchmark id (``AUTHORED-<hex>``) and answers
 63        with it on the returned row. The label is normalised and refused
 64        rather than sanitised if it cannot be one.
 65
 66        Re-uploading the same slug replaces the tree **while it is still
 67        pending**; once an operator has ruled on it the id is fixed. An
 68        approved upload then takes a revision (:meth:`revise`); a rejected
 69        one is kept as it is, and a new version goes under a new name.
 70
 71        The contributor agreement: when the competition names one
 72        (``authoring_terms_version`` / ``authoring_terms_url`` on its
 73        detail), pass that version as ``accepted_terms_version`` and
 74        answer ``license``, ``provenance`` (with ``source_url`` for a
 75        ``port``) and ``ai_assisted``. ``eval_use`` and ``credit`` are
 76        grants, refused unless given.
 77
 78        ``destination`` is where it goes once published (plan P3.4): the
 79        public catalog, or ``eval_only`` — the evaluation pool, kept out of
 80        every public listing and launched only through a competition that
 81        is not public and curates it. The pool takes an original challenge
 82        (``provenance="original"``) whose author granted ``eval_use``;
 83        AI-assisted is allowed, and marked on the evaluation board.
 84
 85        The account must hold a verified email address, on every
 86        deployment: an unproved one is refused
 87        ``authoring_requires_verified_email`` before the archive is read.
 88
 89        ``auto_smoke`` on the returned row says whether the deployment is
 90        smoking the upload on an isolated worker (``queued`` — the verdict
 91        lands on the listing's ``smoke_verdict``) and, if not, why:
 92        ``budget_spent`` carries when the next run is free. ``None`` is a
 93        server that did not say, never *not smoked*.
 94
 95        ⚠️ **Only what you name is sent.** An absent declaration is
 96        *nobody said* on the platform, and putting ``license=`` or
 97        ``eval_use=false`` on the wire for a caller that chose neither
 98        would send a value nobody picked.
 99        """
100        declared: dict[str, str] = {
101            name: value
102            for name, value in (
103                ("license", license),
104                ("provenance", provenance),
105                ("source_url", source_url),
106                ("ai_assisted", ai_assisted),
107                ("accepted_terms_version", accepted_terms_version),
108            )
109            if value
110        }
111        if eval_use:
112            declared["eval_use"] = "true"
113        if credit != "anonymous":
114            declared["credit"] = credit
115        if destination != "public":
116            declared["destination"] = destination
117        resp = self._http.request(
118            "POST",
119            f"/competitions/{competition_id}/authored-challenges",
120            data={"slug": slug, **declared},
121            files={"file": (filename, archive, "application/gzip")},
122        )
123        _raise_for_status(resp)
124        return AuthoredChallengeInfo.model_validate(resp.json())
125
126    def revise(
127        self,
128        competition_id: str,
129        challenge_id: str,
130        *,
131        archive: bytes,
132        filename: str = "challenge.tar.gz",
133    ) -> AuthoredRevisionInfo:
134        """Propose a new tree for one of your **approved** uploads (plan P3.1).
135
136        Any of the upload's recorded authors may. The revision is reviewed
137        as a diff against the live version and a platform admin applies
138        it; until then players keep the live one, and boxes already
139        running keep it until they expire. A second proposal replaces the
140        first, whatever its verdict — the listing's ``revision`` says
141        where the current one stands.
142
143        Refused ``authored_revision_drops_questions`` when the tree drops a
144        question the live version has (solves are recorded against them),
145        ``authored_revision_unchanged`` for an identical tree, and
146        ``authored_challenge_not_revisable`` for an upload that is not
147        approved. The event's authoring window does not apply: a revision
148        maintains a challenge that outlives its event.
149        """
150        resp = self._http.request(
151            "POST",
152            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/revisions",
153            files={"file": (filename, archive, "application/gzip")},
154        )
155        _raise_for_status(resp)
156        return AuthoredRevisionInfo.model_validate(resp.json())
157
158    def template(
159        self,
160        *,
161        category: ChallengeCategory = ChallengeCategory.WEB,
162        stack: Stack | None = None,
163        competition_id: str = "",
164    ) -> bytes:
165        """A starter challenge tree as ``.tar.gz`` bytes.
166
167        The same tree ``ctfy authoring init`` writes and the authoring
168        page downloads — all three call the server's own
169        ``scaffold_authored``, so a template fixed on one path cannot be
170        stale on another.
171
172        ⚠️ **Bytes, not a model.** The route answers an archive; parsing
173        it here would mean this library deciding where an author's
174        files land, which is `_pack`'s job in the opposite direction and
175        the caller's here.
176
177        ⚠️ ``stack`` belongs to ``web`` alone — the server refuses the
178        pair on any other category and refuses ``web`` without one —
179        and it is **omitted** rather than sent empty, since the query
180        parameter is an enum and ``""`` is not a member.
181
182        ⚠️ ``competition_id`` is optional because the template contains
183        nothing an event owns, but it is *accepted*: ``authoring`` is a
184        competition-scoped category, so a ``selected`` fine-grained
185        token names its event here as it does on the upload.
186        """
187        params: dict[str, str] = {"category": category.value}
188        if stack is not None:
189            params["stack"] = stack.value
190        if competition_id:
191            params["competition_id"] = competition_id
192        resp = self._http.request("GET", "/authoring/template", params=params)
193        _raise_for_status(resp)
194        return bytes(resp.content)
195
196    def audit(self, competition_id: str, challenge_id: str) -> AuthoredAuditReport:
197        """What the challenge audit says about one of your uploads.
198
199        A defect list you can fix and re-upload against, without waiting
200        for a reviewer to read the tree and write the same findings out
201        by hand.
202
203        ⚠️ ``outcome`` is the field to read first: ``ok`` means the
204        audit ran and ``errors`` / ``warnings`` are its verdict, while
205        ``timed_out`` and ``failed`` mean it could not say — an empty
206        ``findings`` list is not by itself a clean bill.
207        """
208        resp = self._http.request(
209            "GET",
210            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/audit",
211        )
212        _raise_for_status(resp)
213        return AuthoredAuditReport.model_validate(resp.json())
214
215    def template_manifest(
216        self,
217        *,
218        category: ChallengeCategory = ChallengeCategory.WEB,
219        stack: Stack | None = None,
220        competition_id: str = "",
221    ) -> AuthoringTemplateManifest:
222        """What :meth:`template` would hand over, without downloading it.
223
224        The same tree, described rather than packed — so a caller can
225        show an author the files before spending the transfer, and the
226        two answers cannot disagree because the server builds one tree
227        for both routes.
228
229        ⚠️ The pairing rules are :meth:`template`'s, verbatim, and are
230        not restated here: the server refuses the same shapes with the
231        same message, which is what makes one refusal a menu rather
232        than three.
233        """
234        params: dict[str, str] = {"category": category.value}
235        if stack is not None:
236            params["stack"] = stack.value
237        if competition_id:
238            params["competition_id"] = competition_id
239        resp = self._http.request("GET", "/authoring/template/manifest", params=params)
240        _raise_for_status(resp)
241        return AuthoringTemplateManifest.model_validate(resp.json())
242
243    def list(self, competition_id: str) -> list[AuthoredChallengeRow]:
244        """The calling team's own uploads for one competition.
245
246        ⚠️ The team's, never the event's: before the release point a
247        list of everybody's challenge names is a preview of what the
248        field will have to solve.
249
250        ⚠️ **:class:`AuthoredChallengeRow`, never the ``Info`` the
251        upload answers with, and the difference is invisible without
252        this line.** That model adds ``files`` — the tree's paths — with
253        a ``default_factory``, so validating a listing row against it
254        *succeeds* and hands every caller ``files == []``: not a missing
255        field but a **claim that the upload has no files**, which is the
256        one sentence the two-model split exists to stop. Two live
257        readers would have said it — ``ctfy authoring list --json``
258        emits the key, and the MCP tool hands it to an agent —
259        and `test_sdk_openapi_parity` cannot see it, because that guard
260        asks whether a route is *reached* and never whether the answer
261        is *understood*.
262        """
263        resp = self._http.request("GET", f"/competitions/{competition_id}/authored-challenges")
264        _raise_for_status(resp)
265        return [AuthoredChallengeRow.model_validate(row) for row in resp.json()]
266
267    def reception(self, competition_id: str) -> AuthoringReceptionInfo:
268        """How this event's field fared against the challenges you wrote.
269
270        The other half of the board's authoring column: that one says
271        *how much*, this says which of your challenges earned it and how
272        close each landed to the band the curve pays for.
273
274        ⚠️ Read ``state`` per row before the numbers. ``priced`` is the
275        only one with a rate; ``not_curated`` means the organiser has
276        not put that challenge in this event's set, and ``no_field``
277        that your division is too small to produce a rate at all. Both
278        render as a blank cell and only one of them is about the
279        challenge.
280
281        ⚠️ ``scored`` is ``False`` on every competition not running
282        ``peer_authoring``, with no rows — not an error, just an event
283        that does not pay for authoring.
284        """
285        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/reception")
286        _raise_for_status(resp)
287        return AuthoringReceptionInfo.model_validate(resp.json())
class AuthoringResource:
 38class AuthoringResource:
 39    """Upload and list the calling team's authored challenges."""
 40
 41    def __init__(self, http: BaseHttpClient) -> None:
 42        self._http = http
 43
 44    def upload(
 45        self,
 46        competition_id: str,
 47        *,
 48        slug: str,
 49        archive: bytes,
 50        filename: str = "challenge.tar.gz",
 51        license: AuthoredLicenseState = "",
 52        provenance: AuthoredProvenanceState = "",
 53        source_url: str = "",
 54        ai_assisted: AuthoredAiAssistanceState = "",
 55        eval_use: bool = False,
 56        credit: AuthoredCredit = "anonymous",
 57        accepted_terms_version: str = "",
 58        destination: AuthoredDestination = "public",
 59    ) -> AuthoredChallengeInfo:
 60        """Upload one challenge tree.
 61
 62        ``slug`` is your own label for the upload, never its id: the
 63        platform mints the benchmark id (``AUTHORED-<hex>``) and answers
 64        with it on the returned row. The label is normalised and refused
 65        rather than sanitised if it cannot be one.
 66
 67        Re-uploading the same slug replaces the tree **while it is still
 68        pending**; once an operator has ruled on it the id is fixed. An
 69        approved upload then takes a revision (:meth:`revise`); a rejected
 70        one is kept as it is, and a new version goes under a new name.
 71
 72        The contributor agreement: when the competition names one
 73        (``authoring_terms_version`` / ``authoring_terms_url`` on its
 74        detail), pass that version as ``accepted_terms_version`` and
 75        answer ``license``, ``provenance`` (with ``source_url`` for a
 76        ``port``) and ``ai_assisted``. ``eval_use`` and ``credit`` are
 77        grants, refused unless given.
 78
 79        ``destination`` is where it goes once published (plan P3.4): the
 80        public catalog, or ``eval_only`` — the evaluation pool, kept out of
 81        every public listing and launched only through a competition that
 82        is not public and curates it. The pool takes an original challenge
 83        (``provenance="original"``) whose author granted ``eval_use``;
 84        AI-assisted is allowed, and marked on the evaluation board.
 85
 86        The account must hold a verified email address, on every
 87        deployment: an unproved one is refused
 88        ``authoring_requires_verified_email`` before the archive is read.
 89
 90        ``auto_smoke`` on the returned row says whether the deployment is
 91        smoking the upload on an isolated worker (``queued`` — the verdict
 92        lands on the listing's ``smoke_verdict``) and, if not, why:
 93        ``budget_spent`` carries when the next run is free. ``None`` is a
 94        server that did not say, never *not smoked*.
 95
 96        ⚠️ **Only what you name is sent.** An absent declaration is
 97        *nobody said* on the platform, and putting ``license=`` or
 98        ``eval_use=false`` on the wire for a caller that chose neither
 99        would send a value nobody picked.
100        """
101        declared: dict[str, str] = {
102            name: value
103            for name, value in (
104                ("license", license),
105                ("provenance", provenance),
106                ("source_url", source_url),
107                ("ai_assisted", ai_assisted),
108                ("accepted_terms_version", accepted_terms_version),
109            )
110            if value
111        }
112        if eval_use:
113            declared["eval_use"] = "true"
114        if credit != "anonymous":
115            declared["credit"] = credit
116        if destination != "public":
117            declared["destination"] = destination
118        resp = self._http.request(
119            "POST",
120            f"/competitions/{competition_id}/authored-challenges",
121            data={"slug": slug, **declared},
122            files={"file": (filename, archive, "application/gzip")},
123        )
124        _raise_for_status(resp)
125        return AuthoredChallengeInfo.model_validate(resp.json())
126
127    def revise(
128        self,
129        competition_id: str,
130        challenge_id: str,
131        *,
132        archive: bytes,
133        filename: str = "challenge.tar.gz",
134    ) -> AuthoredRevisionInfo:
135        """Propose a new tree for one of your **approved** uploads (plan P3.1).
136
137        Any of the upload's recorded authors may. The revision is reviewed
138        as a diff against the live version and a platform admin applies
139        it; until then players keep the live one, and boxes already
140        running keep it until they expire. A second proposal replaces the
141        first, whatever its verdict — the listing's ``revision`` says
142        where the current one stands.
143
144        Refused ``authored_revision_drops_questions`` when the tree drops a
145        question the live version has (solves are recorded against them),
146        ``authored_revision_unchanged`` for an identical tree, and
147        ``authored_challenge_not_revisable`` for an upload that is not
148        approved. The event's authoring window does not apply: a revision
149        maintains a challenge that outlives its event.
150        """
151        resp = self._http.request(
152            "POST",
153            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/revisions",
154            files={"file": (filename, archive, "application/gzip")},
155        )
156        _raise_for_status(resp)
157        return AuthoredRevisionInfo.model_validate(resp.json())
158
159    def template(
160        self,
161        *,
162        category: ChallengeCategory = ChallengeCategory.WEB,
163        stack: Stack | None = None,
164        competition_id: str = "",
165    ) -> bytes:
166        """A starter challenge tree as ``.tar.gz`` bytes.
167
168        The same tree ``ctfy authoring init`` writes and the authoring
169        page downloads — all three call the server's own
170        ``scaffold_authored``, so a template fixed on one path cannot be
171        stale on another.
172
173        ⚠️ **Bytes, not a model.** The route answers an archive; parsing
174        it here would mean this library deciding where an author's
175        files land, which is `_pack`'s job in the opposite direction and
176        the caller's here.
177
178        ⚠️ ``stack`` belongs to ``web`` alone — the server refuses the
179        pair on any other category and refuses ``web`` without one —
180        and it is **omitted** rather than sent empty, since the query
181        parameter is an enum and ``""`` is not a member.
182
183        ⚠️ ``competition_id`` is optional because the template contains
184        nothing an event owns, but it is *accepted*: ``authoring`` is a
185        competition-scoped category, so a ``selected`` fine-grained
186        token names its event here as it does on the upload.
187        """
188        params: dict[str, str] = {"category": category.value}
189        if stack is not None:
190            params["stack"] = stack.value
191        if competition_id:
192            params["competition_id"] = competition_id
193        resp = self._http.request("GET", "/authoring/template", params=params)
194        _raise_for_status(resp)
195        return bytes(resp.content)
196
197    def audit(self, competition_id: str, challenge_id: str) -> AuthoredAuditReport:
198        """What the challenge audit says about one of your uploads.
199
200        A defect list you can fix and re-upload against, without waiting
201        for a reviewer to read the tree and write the same findings out
202        by hand.
203
204        ⚠️ ``outcome`` is the field to read first: ``ok`` means the
205        audit ran and ``errors`` / ``warnings`` are its verdict, while
206        ``timed_out`` and ``failed`` mean it could not say — an empty
207        ``findings`` list is not by itself a clean bill.
208        """
209        resp = self._http.request(
210            "GET",
211            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/audit",
212        )
213        _raise_for_status(resp)
214        return AuthoredAuditReport.model_validate(resp.json())
215
216    def template_manifest(
217        self,
218        *,
219        category: ChallengeCategory = ChallengeCategory.WEB,
220        stack: Stack | None = None,
221        competition_id: str = "",
222    ) -> AuthoringTemplateManifest:
223        """What :meth:`template` would hand over, without downloading it.
224
225        The same tree, described rather than packed — so a caller can
226        show an author the files before spending the transfer, and the
227        two answers cannot disagree because the server builds one tree
228        for both routes.
229
230        ⚠️ The pairing rules are :meth:`template`'s, verbatim, and are
231        not restated here: the server refuses the same shapes with the
232        same message, which is what makes one refusal a menu rather
233        than three.
234        """
235        params: dict[str, str] = {"category": category.value}
236        if stack is not None:
237            params["stack"] = stack.value
238        if competition_id:
239            params["competition_id"] = competition_id
240        resp = self._http.request("GET", "/authoring/template/manifest", params=params)
241        _raise_for_status(resp)
242        return AuthoringTemplateManifest.model_validate(resp.json())
243
244    def list(self, competition_id: str) -> list[AuthoredChallengeRow]:
245        """The calling team's own uploads for one competition.
246
247        ⚠️ The team's, never the event's: before the release point a
248        list of everybody's challenge names is a preview of what the
249        field will have to solve.
250
251        ⚠️ **:class:`AuthoredChallengeRow`, never the ``Info`` the
252        upload answers with, and the difference is invisible without
253        this line.** That model adds ``files`` — the tree's paths — with
254        a ``default_factory``, so validating a listing row against it
255        *succeeds* and hands every caller ``files == []``: not a missing
256        field but a **claim that the upload has no files**, which is the
257        one sentence the two-model split exists to stop. Two live
258        readers would have said it — ``ctfy authoring list --json``
259        emits the key, and the MCP tool hands it to an agent —
260        and `test_sdk_openapi_parity` cannot see it, because that guard
261        asks whether a route is *reached* and never whether the answer
262        is *understood*.
263        """
264        resp = self._http.request("GET", f"/competitions/{competition_id}/authored-challenges")
265        _raise_for_status(resp)
266        return [AuthoredChallengeRow.model_validate(row) for row in resp.json()]
267
268    def reception(self, competition_id: str) -> AuthoringReceptionInfo:
269        """How this event's field fared against the challenges you wrote.
270
271        The other half of the board's authoring column: that one says
272        *how much*, this says which of your challenges earned it and how
273        close each landed to the band the curve pays for.
274
275        ⚠️ Read ``state`` per row before the numbers. ``priced`` is the
276        only one with a rate; ``not_curated`` means the organiser has
277        not put that challenge in this event's set, and ``no_field``
278        that your division is too small to produce a rate at all. Both
279        render as a blank cell and only one of them is about the
280        challenge.
281
282        ⚠️ ``scored`` is ``False`` on every competition not running
283        ``peer_authoring``, with no rows — not an error, just an event
284        that does not pay for authoring.
285        """
286        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/reception")
287        _raise_for_status(resp)
288        return AuthoringReceptionInfo.model_validate(resp.json())

Upload and list the calling team's authored challenges.

AuthoringResource(http: ctfy.sdk.base.BaseHttpClient)
41    def __init__(self, http: BaseHttpClient) -> None:
42        self._http = http
def upload( self, competition_id: str, *, slug: str, archive: bytes, filename: str = 'challenge.tar.gz', license: Union[Literal[''], Literal['CC-BY-4.0', 'CC-BY-NC-4.0', 'CC-BY-SA-4.0', 'MIT', 'Apache-2.0', 'platform-only']] = '', provenance: Union[Literal[''], Literal['original', 'port']] = '', source_url: str = '', ai_assisted: Union[Literal[''], Literal['yes', 'no']] = '', eval_use: bool = False, credit: Literal['display_name', 'anonymous'] = 'anonymous', accepted_terms_version: str = '', destination: Literal['public', 'eval_only'] = 'public') -> ctfy.server.models.AuthoredChallengeInfo:
 44    def upload(
 45        self,
 46        competition_id: str,
 47        *,
 48        slug: str,
 49        archive: bytes,
 50        filename: str = "challenge.tar.gz",
 51        license: AuthoredLicenseState = "",
 52        provenance: AuthoredProvenanceState = "",
 53        source_url: str = "",
 54        ai_assisted: AuthoredAiAssistanceState = "",
 55        eval_use: bool = False,
 56        credit: AuthoredCredit = "anonymous",
 57        accepted_terms_version: str = "",
 58        destination: AuthoredDestination = "public",
 59    ) -> AuthoredChallengeInfo:
 60        """Upload one challenge tree.
 61
 62        ``slug`` is your own label for the upload, never its id: the
 63        platform mints the benchmark id (``AUTHORED-<hex>``) and answers
 64        with it on the returned row. The label is normalised and refused
 65        rather than sanitised if it cannot be one.
 66
 67        Re-uploading the same slug replaces the tree **while it is still
 68        pending**; once an operator has ruled on it the id is fixed. An
 69        approved upload then takes a revision (:meth:`revise`); a rejected
 70        one is kept as it is, and a new version goes under a new name.
 71
 72        The contributor agreement: when the competition names one
 73        (``authoring_terms_version`` / ``authoring_terms_url`` on its
 74        detail), pass that version as ``accepted_terms_version`` and
 75        answer ``license``, ``provenance`` (with ``source_url`` for a
 76        ``port``) and ``ai_assisted``. ``eval_use`` and ``credit`` are
 77        grants, refused unless given.
 78
 79        ``destination`` is where it goes once published (plan P3.4): the
 80        public catalog, or ``eval_only`` — the evaluation pool, kept out of
 81        every public listing and launched only through a competition that
 82        is not public and curates it. The pool takes an original challenge
 83        (``provenance="original"``) whose author granted ``eval_use``;
 84        AI-assisted is allowed, and marked on the evaluation board.
 85
 86        The account must hold a verified email address, on every
 87        deployment: an unproved one is refused
 88        ``authoring_requires_verified_email`` before the archive is read.
 89
 90        ``auto_smoke`` on the returned row says whether the deployment is
 91        smoking the upload on an isolated worker (``queued`` — the verdict
 92        lands on the listing's ``smoke_verdict``) and, if not, why:
 93        ``budget_spent`` carries when the next run is free. ``None`` is a
 94        server that did not say, never *not smoked*.
 95
 96        ⚠️ **Only what you name is sent.** An absent declaration is
 97        *nobody said* on the platform, and putting ``license=`` or
 98        ``eval_use=false`` on the wire for a caller that chose neither
 99        would send a value nobody picked.
100        """
101        declared: dict[str, str] = {
102            name: value
103            for name, value in (
104                ("license", license),
105                ("provenance", provenance),
106                ("source_url", source_url),
107                ("ai_assisted", ai_assisted),
108                ("accepted_terms_version", accepted_terms_version),
109            )
110            if value
111        }
112        if eval_use:
113            declared["eval_use"] = "true"
114        if credit != "anonymous":
115            declared["credit"] = credit
116        if destination != "public":
117            declared["destination"] = destination
118        resp = self._http.request(
119            "POST",
120            f"/competitions/{competition_id}/authored-challenges",
121            data={"slug": slug, **declared},
122            files={"file": (filename, archive, "application/gzip")},
123        )
124        _raise_for_status(resp)
125        return AuthoredChallengeInfo.model_validate(resp.json())

Upload one challenge tree.

slug is your own label for the upload, never its id: the platform mints the benchmark id (AUTHORED-<hex>) and answers with it on the returned row. The label is normalised and refused rather than sanitised if it cannot be one.

Re-uploading the same slug replaces the tree while it is still pending; once an operator has ruled on it the id is fixed. An approved upload then takes a revision (revise()); a rejected one is kept as it is, and a new version goes under a new name.

The contributor agreement: when the competition names one (authoring_terms_version / authoring_terms_url on its detail), pass that version as accepted_terms_version and answer license, provenance (with source_url for a port) and ai_assisted. eval_use and credit are grants, refused unless given.

destination is where it goes once published (plan P3.4): the public catalog, or eval_only — the evaluation pool, kept out of every public listing and launched only through a competition that is not public and curates it. The pool takes an original challenge (provenance="original") whose author granted eval_use; AI-assisted is allowed, and marked on the evaluation board.

The account must hold a verified email address, on every deployment: an unproved one is refused authoring_requires_verified_email before the archive is read.

auto_smoke on the returned row says whether the deployment is smoking the upload on an isolated worker (queued — the verdict lands on the listing's smoke_verdict) and, if not, why: budget_spent carries when the next run is free. None is a server that did not say, never not smoked.

⚠️ Only what you name is sent. An absent declaration is nobody said on the platform, and putting license= or eval_use=false on the wire for a caller that chose neither would send a value nobody picked.

def revise( self, competition_id: str, challenge_id: str, *, archive: bytes, filename: str = 'challenge.tar.gz') -> ctfy.server.models.AuthoredRevisionInfo:
127    def revise(
128        self,
129        competition_id: str,
130        challenge_id: str,
131        *,
132        archive: bytes,
133        filename: str = "challenge.tar.gz",
134    ) -> AuthoredRevisionInfo:
135        """Propose a new tree for one of your **approved** uploads (plan P3.1).
136
137        Any of the upload's recorded authors may. The revision is reviewed
138        as a diff against the live version and a platform admin applies
139        it; until then players keep the live one, and boxes already
140        running keep it until they expire. A second proposal replaces the
141        first, whatever its verdict — the listing's ``revision`` says
142        where the current one stands.
143
144        Refused ``authored_revision_drops_questions`` when the tree drops a
145        question the live version has (solves are recorded against them),
146        ``authored_revision_unchanged`` for an identical tree, and
147        ``authored_challenge_not_revisable`` for an upload that is not
148        approved. The event's authoring window does not apply: a revision
149        maintains a challenge that outlives its event.
150        """
151        resp = self._http.request(
152            "POST",
153            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/revisions",
154            files={"file": (filename, archive, "application/gzip")},
155        )
156        _raise_for_status(resp)
157        return AuthoredRevisionInfo.model_validate(resp.json())

Propose a new tree for one of your approved uploads (plan P3.1).

Any of the upload's recorded authors may. The revision is reviewed as a diff against the live version and a platform admin applies it; until then players keep the live one, and boxes already running keep it until they expire. A second proposal replaces the first, whatever its verdict — the listing's revision says where the current one stands.

Refused authored_revision_drops_questions when the tree drops a question the live version has (solves are recorded against them), authored_revision_unchanged for an identical tree, and authored_challenge_not_revisable for an upload that is not approved. The event's authoring window does not apply: a revision maintains a challenge that outlives its event.

def template( self, *, category: ctfy.challenge.benchmark.ChallengeCategory = <ChallengeCategory.WEB: 'web'>, stack: ctfy.challenge.scaffold.Stack | None = None, competition_id: str = '') -> bytes:
159    def template(
160        self,
161        *,
162        category: ChallengeCategory = ChallengeCategory.WEB,
163        stack: Stack | None = None,
164        competition_id: str = "",
165    ) -> bytes:
166        """A starter challenge tree as ``.tar.gz`` bytes.
167
168        The same tree ``ctfy authoring init`` writes and the authoring
169        page downloads — all three call the server's own
170        ``scaffold_authored``, so a template fixed on one path cannot be
171        stale on another.
172
173        ⚠️ **Bytes, not a model.** The route answers an archive; parsing
174        it here would mean this library deciding where an author's
175        files land, which is `_pack`'s job in the opposite direction and
176        the caller's here.
177
178        ⚠️ ``stack`` belongs to ``web`` alone — the server refuses the
179        pair on any other category and refuses ``web`` without one —
180        and it is **omitted** rather than sent empty, since the query
181        parameter is an enum and ``""`` is not a member.
182
183        ⚠️ ``competition_id`` is optional because the template contains
184        nothing an event owns, but it is *accepted*: ``authoring`` is a
185        competition-scoped category, so a ``selected`` fine-grained
186        token names its event here as it does on the upload.
187        """
188        params: dict[str, str] = {"category": category.value}
189        if stack is not None:
190            params["stack"] = stack.value
191        if competition_id:
192            params["competition_id"] = competition_id
193        resp = self._http.request("GET", "/authoring/template", params=params)
194        _raise_for_status(resp)
195        return bytes(resp.content)

A starter challenge tree as .tar.gz bytes.

The same tree ctfy authoring init writes and the authoring page downloads — all three call the server's own scaffold_authored, so a template fixed on one path cannot be stale on another.

⚠️ Bytes, not a model. The route answers an archive; parsing it here would mean this library deciding where an author's files land, which is _pack's job in the opposite direction and the caller's here.

⚠️ stack belongs to web alone — the server refuses the pair on any other category and refuses web without one — and it is omitted rather than sent empty, since the query parameter is an enum and "" is not a member.

⚠️ competition_id is optional because the template contains nothing an event owns, but it is accepted: authoring is a competition-scoped category, so a selected fine-grained token names its event here as it does on the upload.

def audit( self, competition_id: str, challenge_id: str) -> ctfy.server.models.AuthoredAuditReport:
197    def audit(self, competition_id: str, challenge_id: str) -> AuthoredAuditReport:
198        """What the challenge audit says about one of your uploads.
199
200        A defect list you can fix and re-upload against, without waiting
201        for a reviewer to read the tree and write the same findings out
202        by hand.
203
204        ⚠️ ``outcome`` is the field to read first: ``ok`` means the
205        audit ran and ``errors`` / ``warnings`` are its verdict, while
206        ``timed_out`` and ``failed`` mean it could not say — an empty
207        ``findings`` list is not by itself a clean bill.
208        """
209        resp = self._http.request(
210            "GET",
211            f"/competitions/{competition_id}/authored-challenges/{challenge_id}/audit",
212        )
213        _raise_for_status(resp)
214        return AuthoredAuditReport.model_validate(resp.json())

What the challenge audit says about one of your uploads.

A defect list you can fix and re-upload against, without waiting for a reviewer to read the tree and write the same findings out by hand.

⚠️ outcome is the field to read first: ok means the audit ran and errors / warnings are its verdict, while timed_out and failed mean it could not say — an empty findings list is not by itself a clean bill.

def template_manifest( self, *, category: ctfy.challenge.benchmark.ChallengeCategory = <ChallengeCategory.WEB: 'web'>, stack: ctfy.challenge.scaffold.Stack | None = None, competition_id: str = '') -> ctfy.server.models.AuthoringTemplateManifest:
216    def template_manifest(
217        self,
218        *,
219        category: ChallengeCategory = ChallengeCategory.WEB,
220        stack: Stack | None = None,
221        competition_id: str = "",
222    ) -> AuthoringTemplateManifest:
223        """What :meth:`template` would hand over, without downloading it.
224
225        The same tree, described rather than packed — so a caller can
226        show an author the files before spending the transfer, and the
227        two answers cannot disagree because the server builds one tree
228        for both routes.
229
230        ⚠️ The pairing rules are :meth:`template`'s, verbatim, and are
231        not restated here: the server refuses the same shapes with the
232        same message, which is what makes one refusal a menu rather
233        than three.
234        """
235        params: dict[str, str] = {"category": category.value}
236        if stack is not None:
237            params["stack"] = stack.value
238        if competition_id:
239            params["competition_id"] = competition_id
240        resp = self._http.request("GET", "/authoring/template/manifest", params=params)
241        _raise_for_status(resp)
242        return AuthoringTemplateManifest.model_validate(resp.json())

What template() would hand over, without downloading it.

The same tree, described rather than packed — so a caller can show an author the files before spending the transfer, and the two answers cannot disagree because the server builds one tree for both routes.

⚠️ The pairing rules are template()'s, verbatim, and are not restated here: the server refuses the same shapes with the same message, which is what makes one refusal a menu rather than three.

def list(self, competition_id: str) -> 'list[AuthoredChallengeRow]':
244    def list(self, competition_id: str) -> list[AuthoredChallengeRow]:
245        """The calling team's own uploads for one competition.
246
247        ⚠️ The team's, never the event's: before the release point a
248        list of everybody's challenge names is a preview of what the
249        field will have to solve.
250
251        ⚠️ **:class:`AuthoredChallengeRow`, never the ``Info`` the
252        upload answers with, and the difference is invisible without
253        this line.** That model adds ``files`` — the tree's paths — with
254        a ``default_factory``, so validating a listing row against it
255        *succeeds* and hands every caller ``files == []``: not a missing
256        field but a **claim that the upload has no files**, which is the
257        one sentence the two-model split exists to stop. Two live
258        readers would have said it — ``ctfy authoring list --json``
259        emits the key, and the MCP tool hands it to an agent —
260        and `test_sdk_openapi_parity` cannot see it, because that guard
261        asks whether a route is *reached* and never whether the answer
262        is *understood*.
263        """
264        resp = self._http.request("GET", f"/competitions/{competition_id}/authored-challenges")
265        _raise_for_status(resp)
266        return [AuthoredChallengeRow.model_validate(row) for row in resp.json()]

The calling team's own uploads for one competition.

⚠️ The team's, never the event's: before the release point a list of everybody's challenge names is a preview of what the field will have to solve.

⚠️ AuthoredChallengeRow, never the Info the upload answers with, and the difference is invisible without this line. That model adds files — the tree's paths — with a default_factory, so validating a listing row against it succeeds and hands every caller files == []: not a missing field but a claim that the upload has no files, which is the one sentence the two-model split exists to stop. Two live readers would have said it — ctfy authoring list --json emits the key, and the MCP tool hands it to an agent — and test_sdk_openapi_parity cannot see it, because that guard asks whether a route is reached and never whether the answer is understood.

def reception( self, competition_id: str) -> ctfy.server.models.AuthoringReceptionInfo:
268    def reception(self, competition_id: str) -> AuthoringReceptionInfo:
269        """How this event's field fared against the challenges you wrote.
270
271        The other half of the board's authoring column: that one says
272        *how much*, this says which of your challenges earned it and how
273        close each landed to the band the curve pays for.
274
275        ⚠️ Read ``state`` per row before the numbers. ``priced`` is the
276        only one with a rate; ``not_curated`` means the organiser has
277        not put that challenge in this event's set, and ``no_field``
278        that your division is too small to produce a rate at all. Both
279        render as a blank cell and only one of them is about the
280        challenge.
281
282        ⚠️ ``scored`` is ``False`` on every competition not running
283        ``peer_authoring``, with no rows — not an error, just an event
284        that does not pay for authoring.
285        """
286        resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/reception")
287        _raise_for_status(resp)
288        return AuthoringReceptionInfo.model_validate(resp.json())

How this event's field fared against the challenges you wrote.

The other half of the board's authoring column: that one says how much, this says which of your challenges earned it and how close each landed to the band the curve pays for.

⚠️ Read state per row before the numbers. priced is the only one with a rate; not_curated means the organiser has not put that challenge in this event's set, and no_field that your division is too small to produce a rate at all. Both render as a blank cell and only one of them is about the challenge.

⚠️ scored is False on every competition not running peer_authoring, with no rows — not an error, just an event that does not pay for authoring.