ctfy.sdk.admin_resources.awd

client.admin.awd — configuring a classic-AWD match.

  1"""``client.admin.awd`` — configuring a classic-AWD match."""
  2
  3from __future__ import annotations
  4
  5from collections.abc import Mapping
  6from datetime import datetime
  7
  8from ctfy.core.enums import (
  9    AwdAttacker,
 10    AwdDefenceMode,
 11    AwdOpenShellCredential,
 12    AwdShellExposure,
 13)
 14from ctfy.sdk._helpers import _raise_for_status
 15from ctfy.sdk.base import BaseHttpClient
 16from ctfy.server.models import (
 17    AdminTaskInfo,
 18    AwdArenaPeersResponse,
 19    AwdAttackAnchorResponse,
 20    AwdCorroborationResponse,
 21    AwdDefenceAnchorResponse,
 22    AwdForensicsResponse,
 23    AwdMatchInfo,
 24    AwdProvisionPlanInfo,
 25    AwdSlaHistoryResponse,
 26)
 27
 28
 29class AdminAwdResource:
 30    """The organiser's half of a match: cadence, gamebox, budgets.
 31
 32    ⚠️ **The round number is derived from the clock, not stored**, so
 33    ``starts_at`` / ``tick_seconds`` / ``challenge_id`` are frozen once
 34    the match has started — editing one would retroactively renumber
 35    every round already settled and published. :meth:`configure`
 36    answers 409 ``awd_match_locked`` naming the fields, rather than
 37    silently accepting a write that breaks the board.
 38
 39    There is deliberately no way to set ``last_tick_done``: it is the
 40    tick loop's bookmark, and moving it makes the loop re-settle rounds
 41    the board has already published.
 42    """
 43
 44    def __init__(self, http: BaseHttpClient) -> None:
 45        self._http = http
 46
 47    def get(self, competition_id: str) -> AwdMatchInfo:
 48        """This competition's match. 404 if it runs none.
 49
 50        ``current_tick`` and ``started`` are computed per read — asking
 51        again a round later gives a different answer with no write in
 52        between, which is the point.
 53        """
 54        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/match")
 55        _raise_for_status(resp)
 56        return AwdMatchInfo.model_validate(resp.json())
 57
 58    def configure(
 59        self,
 60        competition_id: str,
 61        *,
 62        tick_seconds: int,
 63        tick_count: int,
 64        challenge_id: str = "",
 65        starts_at: datetime | None = None,
 66        per_tick_budget: int | None = None,
 67        total_budget: int | None = None,
 68        attacker: str = AwdAttacker.NONE.value,
 69        reference_victim: bool = False,
 70        defence_mode: str = AwdDefenceMode.PATCH.value,
 71        shell_exposure: str = AwdShellExposure.MANAGED.value,
 72        open_shell_credential: str = AwdOpenShellCredential.OWNER_RANDOM.value,
 73        scoring_params: Mapping[str, float] | None = None,
 74    ) -> AwdMatchInfo:
 75        """Create the match, or edit it. Idempotent on the competition.
 76
 77        ``challenge_id`` may be left empty while the match is being put
 78        together — provisioning is what refuses without a gamebox, and
 79        it refuses at the point an organiser can still act on it.
 80
 81        ⚠️ **This is a full replace, and every argument above is
 82        therefore a value rather than an option.** The route writes
 83        ``existing.model_copy(update=body)``, so an argument left at its
 84        default is *written*, not skipped: calling this to change the
 85        cadence of an armed match disarms the platform attacker and
 86        discards the organiser's scoring knobs. Read the row and pass it
 87        back if you mean "edit one field" — which is what ``ctfy-admin
 88        admin awd set`` does, and why it exists as more than a wrapper.
 89
 90        Kept as a replace on purpose: a caller that means *disarm this*
 91        has to be able to say so, and a library that quietly reads the
 92        current row before writing turns one request into a
 93        read-then-write race whose loser is somebody else's edit.
 94        """
 95        body: dict[str, object] = {
 96            "challenge_id": challenge_id,
 97            "starts_at": starts_at.isoformat() if starts_at else None,
 98            "tick_seconds": tick_seconds,
 99            "tick_count": tick_count,
100            "per_tick_budget": per_tick_budget,
101            "total_budget": total_budget,
102            "attacker": attacker,
103            "reference_victim": reference_victim,
104            "defence_mode": defence_mode,
105            "shell_exposure": shell_exposure,
106            "open_shell_credential": open_shell_credential,
107            "scoring_params": dict(scoring_params or {}),
108        }
109        resp = self._http.request(
110            "PUT", f"/admin/competitions/{competition_id}/awd/match", json=body
111        )
112        _raise_for_status(resp)
113        return AwdMatchInfo.model_validate(resp.json())
114
115    def delete(self, competition_id: str) -> None:
116        """Remove a match that never started and holds no boxes.
117
118        Refused once the first round has opened — its scores are
119        published and keyed by round, so the configuration they belong
120        to has to stay. Lowering ``tick_count`` is how a running match
121        is ended.
122        """
123        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/match")
124        _raise_for_status(resp)
125
126    def plan(self, competition_id: str) -> AwdProvisionPlanInfo:
127        """What opening the arena would do, without doing it.
128
129        409 ``awd_provision_refused`` when the match cannot be
130        provisioned at all — no gamebox, no teams, or a match that has
131        already started (§4.5 fixes the box supply at kickoff).
132        """
133        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/plan")
134        _raise_for_status(resp)
135        return AwdProvisionPlanInfo.model_validate(resp.json())
136
137    def arena_peers(self, competition_id: str) -> AwdArenaPeersResponse:
138        """Who is on this match's arena, and whether their tunnel carries.
139
140        One row per person: the worker their tunnel terminates on, the
141        address they hold, and what the last round's probe found — no
142        handshake, a handshake with no ping, or small packets through and
143        full-size ones not, which are three different repairs on three
144        different machines.
145
146        ``link`` is ``None`` when no round has probed that peer yet,
147        which is deliberately not the same as a peer that is down.
148
149        Carries no config and no key: knowing *that* somebody is
150        connected is the job, and the private half is stored nowhere.
151        """
152        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/vpn-peers")
153        _raise_for_status(resp)
154        return AwdArenaPeersResponse.model_validate(resp.json())
155
156    def corroboration(self, competition_id: str, tick: int) -> AwdCorroborationResponse:
157        """Which of a round's captures the victims' own traffic backs up.
158
159        A flag is bound to ``(victim, service, tick)`` and not to whoever
160        went and got it, so passing one between teams scores — a
161        structural hole in AWD. What a platform with packet capture adds
162        is cost: an attacker who really took the flag was on the box, so
163        their arena address is in that box's capture.
164
165        **Reports, never rules.** No score moves and no flag is refused;
166        the submission path does not call this. ``verdict`` is
167        three-valued and only ``absent`` says anything about the
168        attacker — ``unreadable`` is every way of not finding somebody
169        that is not evidence *about* them (no sidecar on their box, a
170        box that recorded nothing because its service is not HTTP, or an
171        attacking team with no arena address on record at all).
172
173        ``totals`` carries every verdict at zero, so a round that is
174        entirely unreadable says so rather than reading as "no
175        findings".
176        """
177        resp = self._http.request(
178            "GET",
179            f"/admin/competitions/{competition_id}/awd/corroboration",
180            params={"tick": tick},
181        )
182        _raise_for_status(resp)
183        return AwdCorroborationResponse.model_validate(resp.json())
184
185    def defence_anchor(self, competition_id: str) -> AwdDefenceAnchorResponse:
186        """Who blocked the reference exploit, and over how many rounds.
187
188        A model-vs-model ranking is **relative** — a weak field flatters
189        everybody on it — so it does not compare across matches, which
190        for an evaluation product is the whole problem. The reference
191        attacker is the fixed half: every entrant, in every match, meets
192        the same ``solution/exploit.py``, so "held N of the M rounds it
193        ran" carries the same meaning everywhere.
194
195        ⚠️ **M is not the round count.** ``held`` is counted only over
196        rounds in which the exploit actually executed: a round the
197        platform never attacked and a round the defender held leave the
198        same absence, so a scale derived from captures alone credits an
199        entrant for every round the platform failed to attack them.
200        ``not_attempted`` is how much of the match went unmeasured, and
201        it is the platform's fault rather than theirs.
202
203        ``hold_rate`` is ``None`` — never ``0.0`` — for an entrant with
204        no attempted rounds: a zero would assert they were breached
205        every round.
206
207        ``armed`` is ``False`` for a match that runs no reference
208        attacker at all, which is a different thing from an attacker
209        that found nothing.
210        """
211        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/anchor")
212        _raise_for_status(resp)
213        return AwdDefenceAnchorResponse.model_validate(resp.json())
214
215    def sla_history(self, competition_id: str, *, team_id: str = "") -> AwdSlaHistoryResponse:
216        """When in each round this match's SLA probes actually landed.
217
218        A check row records *which* round and, until recently, nothing
219        about *when in it* — so "was my box probed before I had a chance
220        to repair it", the question a disputed deduction turns on, could
221        only be answered by reading the tick loop. Each row here carries
222        the seconds from its own round's opening, and ``spread_s`` is how
223        much later the last box was asked than the first.
224
225        ``offset_s`` is ``None`` for a row written before the column
226        existed, and for a match with no kickoff to measure from. It is
227        never ``0.0`` for those: zero means the probe landed on the
228        round's opening second.
229        """
230        params: dict[str, str] = {}
231        if team_id:
232            params["team_id"] = team_id
233        resp = self._http.request(
234            "GET", f"/admin/competitions/{competition_id}/awd/sla-history", params=params or None
235        )
236        return AwdSlaHistoryResponse.model_validate(resp.json())
237
238    def attack_anchor(self, competition_id: str) -> AwdAttackAnchorResponse:
239        """Who broke the reference victim, and over how many rounds.
240
241        The mirror of :meth:`defence_anchor`. That one fixes the
242        **attacker** every entrant meets; this one fixes the
243        **victim** — §16.6's free target, one unpatched box owned by
244        nobody and never defended — so "broke a known-vulnerable box in
245        N of the M rounds it was up" carries the same meaning in every
246        match, where a place on the relative board is a statement about
247        the field an entrant happened to draw.
248
249        ⚠️ **M is the free box's own SLA record, not the round count.**
250        Nobody can break a box that was down, so debiting every entrant
251        for a round the instrument spent broken makes the number a
252        reading of our uptime rather than their attack. ``offered`` is
253        that denominator and is **match-wide**: every row carries the
254        same value, which is what makes two entrants comparable.
255
256        ``breach_rate`` is ``None`` — never ``0.0`` — when nothing was
257        offered, and ``first_breach_tick`` is ``None`` — never ``-1``
258        and never ``0``, since round 0 is a real round — for an entrant
259        who never broke it.
260
261        ``armed`` is ``False`` for a match that runs no reference victim
262        at all, which is a different thing from a free box nobody broke.
263        """
264        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/attack-anchor")
265        _raise_for_status(resp)
266        return AwdAttackAnchorResponse.model_validate(resp.json())
267
268    def forensics(self, competition_id: str) -> AwdForensicsResponse:
269        """§4.3.1's machine half: who found the attacker in their own box.
270
271        The platform's agent attacker carries a marker per ``(team,
272        round)`` into every gamebox. A defender who instrumented their
273        box can locate where that round's attacker touched it and quote
274        the value back in their engagement report; this is how much of
275        what reached them they found. It is the shallow half on
276        purpose — "did you keep a log and did you read it" — and the
277        model-judged half is what stops a forensic range being a grep
278        exercise.
279
280        ⚠️ **``offered`` is not the round count.** `defence_anchor`'s
281        rule through a third door: the attacker carrying these markers
282        is an LLM and therefore random, so a round it never landed in is
283        a round the *platform* failed to attack. Charging the team for
284        it would make the number a reading of our own uptime wearing
285        their name.
286
287        ``discovery_rate`` is ``None`` — never ``0.0`` — for an entrant
288        nothing reached.
289
290        ⚠️ **``armed`` reads whether the attacker carries an environment
291        at all**, which is a narrower question than "is there an
292        attacker": a reference-exploit match attacks every round and
293        mints no marker, because `build_attack_argv` passes no
294        environment and EXPLOIT007's guarantee rests on that. Every row
295        would then read ``offered: 0``, byte-identical to a field the
296        attacker reached and nobody traced.
297
298        ``reported`` annotates rather than decides: a team with no
299        report and a team whose report quotes nothing score the same,
300        and correctly — what differs is the repair.
301        """
302        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/forensics")
303        _raise_for_status(resp)
304        return AwdForensicsResponse.model_validate(resp.json())
305
306    def grade_forensics(self, competition_id: str) -> AdminTaskInfo:
307        """§4.3.1's model half: judge every entrant's write-up, now.
308
309        Returns the **task**, not the readings: this is one model call
310        per entrant, so a field of a dozen is a minute of work. Poll
311        ``client.admin.tasks.get(id)``, then read :meth:`forensics`,
312        whose rows carry ``analysis`` once it has run.
313
314        ⚠️ **A press rather than a cadence**, unlike the exercise
315        grader: a GAUNTLET report is drafted over days and graded on a
316        quiet window, while a match *ends* and the write-up is judged
317        once against the document it finished with.
318
319        ⚠️ **It spends a model call per entrant every press**, and the
320        task reports ``skipped`` rather than refusing when this
321        deployment has no gateway — ``forensics()``'s
322        ``analysis_configured`` is what says so before you press.
323
324        ⚠️ The verdicts are about **depth, never accuracy**: with no
325        answer key a model cannot say whether a stated root cause is
326        right, only whether the report states one specifically. A row
327        reading ``root_cause: specific`` is not the platform agreeing
328        with the defender.
329        """
330        resp = self._http.request(
331            "POST", f"/admin/competitions/{competition_id}/awd/forensics/grade"
332        )
333        _raise_for_status(resp)
334        return AdminTaskInfo.model_validate(resp.json())
335
336    def provision(self, competition_id: str) -> AdminTaskInfo:
337        """Start a gamebox for every team that lacks one.
338
339        Returns the **task**, not the boxes: a fleet launch is minutes
340        of work at event scale. Poll ``client.admin.tasks.get(id)`` (or
341        ``.logs``, which names every team that failed) for the outcome.
342        Idempotent — re-running skips the teams already holding a box.
343        """
344        resp = self._http.request("POST", f"/admin/competitions/{competition_id}/awd/provision")
345        _raise_for_status(resp)
346        return AdminTaskInfo.model_validate(resp.json())
347
348    def start_anyway(self, competition_id: str) -> AwdMatchInfo:
349        """Start the match although its arena was not measured whole (#1713).
350
351        The provisioning run checks every link between the arena's nodes
352        before kickoff; when that check is not ``whole`` the tick settles
353        no round until the arena is re-checked whole or somebody presses
354        this. Records who and when as an audit fact. Refused with 409
355        ``awd_kickoff_not_held`` when nothing is being held.
356        """
357        resp = self._http.request(
358            "POST", f"/admin/competitions/{competition_id}/awd/kickoff-override"
359        )
360        _raise_for_status(resp)
361        return AwdMatchInfo.model_validate(resp.json())
362
363    def teardown(self, competition_id: str) -> AdminTaskInfo:
364        """Stop every arena box of this match. Also a task.
365
366        Only this competition's ``lease="match"`` boxes: a player's own
367        on-demand instance is not part of the match, and no node is
368        wiped wholesale — the arena shares hosts with every other event
369        on the platform.
370        """
371        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/boxes")
372        _raise_for_status(resp)
373        return AdminTaskInfo.model_validate(resp.json())
class AdminAwdResource:
 30class AdminAwdResource:
 31    """The organiser's half of a match: cadence, gamebox, budgets.
 32
 33    ⚠️ **The round number is derived from the clock, not stored**, so
 34    ``starts_at`` / ``tick_seconds`` / ``challenge_id`` are frozen once
 35    the match has started — editing one would retroactively renumber
 36    every round already settled and published. :meth:`configure`
 37    answers 409 ``awd_match_locked`` naming the fields, rather than
 38    silently accepting a write that breaks the board.
 39
 40    There is deliberately no way to set ``last_tick_done``: it is the
 41    tick loop's bookmark, and moving it makes the loop re-settle rounds
 42    the board has already published.
 43    """
 44
 45    def __init__(self, http: BaseHttpClient) -> None:
 46        self._http = http
 47
 48    def get(self, competition_id: str) -> AwdMatchInfo:
 49        """This competition's match. 404 if it runs none.
 50
 51        ``current_tick`` and ``started`` are computed per read — asking
 52        again a round later gives a different answer with no write in
 53        between, which is the point.
 54        """
 55        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/match")
 56        _raise_for_status(resp)
 57        return AwdMatchInfo.model_validate(resp.json())
 58
 59    def configure(
 60        self,
 61        competition_id: str,
 62        *,
 63        tick_seconds: int,
 64        tick_count: int,
 65        challenge_id: str = "",
 66        starts_at: datetime | None = None,
 67        per_tick_budget: int | None = None,
 68        total_budget: int | None = None,
 69        attacker: str = AwdAttacker.NONE.value,
 70        reference_victim: bool = False,
 71        defence_mode: str = AwdDefenceMode.PATCH.value,
 72        shell_exposure: str = AwdShellExposure.MANAGED.value,
 73        open_shell_credential: str = AwdOpenShellCredential.OWNER_RANDOM.value,
 74        scoring_params: Mapping[str, float] | None = None,
 75    ) -> AwdMatchInfo:
 76        """Create the match, or edit it. Idempotent on the competition.
 77
 78        ``challenge_id`` may be left empty while the match is being put
 79        together — provisioning is what refuses without a gamebox, and
 80        it refuses at the point an organiser can still act on it.
 81
 82        ⚠️ **This is a full replace, and every argument above is
 83        therefore a value rather than an option.** The route writes
 84        ``existing.model_copy(update=body)``, so an argument left at its
 85        default is *written*, not skipped: calling this to change the
 86        cadence of an armed match disarms the platform attacker and
 87        discards the organiser's scoring knobs. Read the row and pass it
 88        back if you mean "edit one field" — which is what ``ctfy-admin
 89        admin awd set`` does, and why it exists as more than a wrapper.
 90
 91        Kept as a replace on purpose: a caller that means *disarm this*
 92        has to be able to say so, and a library that quietly reads the
 93        current row before writing turns one request into a
 94        read-then-write race whose loser is somebody else's edit.
 95        """
 96        body: dict[str, object] = {
 97            "challenge_id": challenge_id,
 98            "starts_at": starts_at.isoformat() if starts_at else None,
 99            "tick_seconds": tick_seconds,
100            "tick_count": tick_count,
101            "per_tick_budget": per_tick_budget,
102            "total_budget": total_budget,
103            "attacker": attacker,
104            "reference_victim": reference_victim,
105            "defence_mode": defence_mode,
106            "shell_exposure": shell_exposure,
107            "open_shell_credential": open_shell_credential,
108            "scoring_params": dict(scoring_params or {}),
109        }
110        resp = self._http.request(
111            "PUT", f"/admin/competitions/{competition_id}/awd/match", json=body
112        )
113        _raise_for_status(resp)
114        return AwdMatchInfo.model_validate(resp.json())
115
116    def delete(self, competition_id: str) -> None:
117        """Remove a match that never started and holds no boxes.
118
119        Refused once the first round has opened — its scores are
120        published and keyed by round, so the configuration they belong
121        to has to stay. Lowering ``tick_count`` is how a running match
122        is ended.
123        """
124        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/match")
125        _raise_for_status(resp)
126
127    def plan(self, competition_id: str) -> AwdProvisionPlanInfo:
128        """What opening the arena would do, without doing it.
129
130        409 ``awd_provision_refused`` when the match cannot be
131        provisioned at all — no gamebox, no teams, or a match that has
132        already started (§4.5 fixes the box supply at kickoff).
133        """
134        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/plan")
135        _raise_for_status(resp)
136        return AwdProvisionPlanInfo.model_validate(resp.json())
137
138    def arena_peers(self, competition_id: str) -> AwdArenaPeersResponse:
139        """Who is on this match's arena, and whether their tunnel carries.
140
141        One row per person: the worker their tunnel terminates on, the
142        address they hold, and what the last round's probe found — no
143        handshake, a handshake with no ping, or small packets through and
144        full-size ones not, which are three different repairs on three
145        different machines.
146
147        ``link`` is ``None`` when no round has probed that peer yet,
148        which is deliberately not the same as a peer that is down.
149
150        Carries no config and no key: knowing *that* somebody is
151        connected is the job, and the private half is stored nowhere.
152        """
153        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/vpn-peers")
154        _raise_for_status(resp)
155        return AwdArenaPeersResponse.model_validate(resp.json())
156
157    def corroboration(self, competition_id: str, tick: int) -> AwdCorroborationResponse:
158        """Which of a round's captures the victims' own traffic backs up.
159
160        A flag is bound to ``(victim, service, tick)`` and not to whoever
161        went and got it, so passing one between teams scores — a
162        structural hole in AWD. What a platform with packet capture adds
163        is cost: an attacker who really took the flag was on the box, so
164        their arena address is in that box's capture.
165
166        **Reports, never rules.** No score moves and no flag is refused;
167        the submission path does not call this. ``verdict`` is
168        three-valued and only ``absent`` says anything about the
169        attacker — ``unreadable`` is every way of not finding somebody
170        that is not evidence *about* them (no sidecar on their box, a
171        box that recorded nothing because its service is not HTTP, or an
172        attacking team with no arena address on record at all).
173
174        ``totals`` carries every verdict at zero, so a round that is
175        entirely unreadable says so rather than reading as "no
176        findings".
177        """
178        resp = self._http.request(
179            "GET",
180            f"/admin/competitions/{competition_id}/awd/corroboration",
181            params={"tick": tick},
182        )
183        _raise_for_status(resp)
184        return AwdCorroborationResponse.model_validate(resp.json())
185
186    def defence_anchor(self, competition_id: str) -> AwdDefenceAnchorResponse:
187        """Who blocked the reference exploit, and over how many rounds.
188
189        A model-vs-model ranking is **relative** — a weak field flatters
190        everybody on it — so it does not compare across matches, which
191        for an evaluation product is the whole problem. The reference
192        attacker is the fixed half: every entrant, in every match, meets
193        the same ``solution/exploit.py``, so "held N of the M rounds it
194        ran" carries the same meaning everywhere.
195
196        ⚠️ **M is not the round count.** ``held`` is counted only over
197        rounds in which the exploit actually executed: a round the
198        platform never attacked and a round the defender held leave the
199        same absence, so a scale derived from captures alone credits an
200        entrant for every round the platform failed to attack them.
201        ``not_attempted`` is how much of the match went unmeasured, and
202        it is the platform's fault rather than theirs.
203
204        ``hold_rate`` is ``None`` — never ``0.0`` — for an entrant with
205        no attempted rounds: a zero would assert they were breached
206        every round.
207
208        ``armed`` is ``False`` for a match that runs no reference
209        attacker at all, which is a different thing from an attacker
210        that found nothing.
211        """
212        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/anchor")
213        _raise_for_status(resp)
214        return AwdDefenceAnchorResponse.model_validate(resp.json())
215
216    def sla_history(self, competition_id: str, *, team_id: str = "") -> AwdSlaHistoryResponse:
217        """When in each round this match's SLA probes actually landed.
218
219        A check row records *which* round and, until recently, nothing
220        about *when in it* — so "was my box probed before I had a chance
221        to repair it", the question a disputed deduction turns on, could
222        only be answered by reading the tick loop. Each row here carries
223        the seconds from its own round's opening, and ``spread_s`` is how
224        much later the last box was asked than the first.
225
226        ``offset_s`` is ``None`` for a row written before the column
227        existed, and for a match with no kickoff to measure from. It is
228        never ``0.0`` for those: zero means the probe landed on the
229        round's opening second.
230        """
231        params: dict[str, str] = {}
232        if team_id:
233            params["team_id"] = team_id
234        resp = self._http.request(
235            "GET", f"/admin/competitions/{competition_id}/awd/sla-history", params=params or None
236        )
237        return AwdSlaHistoryResponse.model_validate(resp.json())
238
239    def attack_anchor(self, competition_id: str) -> AwdAttackAnchorResponse:
240        """Who broke the reference victim, and over how many rounds.
241
242        The mirror of :meth:`defence_anchor`. That one fixes the
243        **attacker** every entrant meets; this one fixes the
244        **victim** — §16.6's free target, one unpatched box owned by
245        nobody and never defended — so "broke a known-vulnerable box in
246        N of the M rounds it was up" carries the same meaning in every
247        match, where a place on the relative board is a statement about
248        the field an entrant happened to draw.
249
250        ⚠️ **M is the free box's own SLA record, not the round count.**
251        Nobody can break a box that was down, so debiting every entrant
252        for a round the instrument spent broken makes the number a
253        reading of our uptime rather than their attack. ``offered`` is
254        that denominator and is **match-wide**: every row carries the
255        same value, which is what makes two entrants comparable.
256
257        ``breach_rate`` is ``None`` — never ``0.0`` — when nothing was
258        offered, and ``first_breach_tick`` is ``None`` — never ``-1``
259        and never ``0``, since round 0 is a real round — for an entrant
260        who never broke it.
261
262        ``armed`` is ``False`` for a match that runs no reference victim
263        at all, which is a different thing from a free box nobody broke.
264        """
265        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/attack-anchor")
266        _raise_for_status(resp)
267        return AwdAttackAnchorResponse.model_validate(resp.json())
268
269    def forensics(self, competition_id: str) -> AwdForensicsResponse:
270        """§4.3.1's machine half: who found the attacker in their own box.
271
272        The platform's agent attacker carries a marker per ``(team,
273        round)`` into every gamebox. A defender who instrumented their
274        box can locate where that round's attacker touched it and quote
275        the value back in their engagement report; this is how much of
276        what reached them they found. It is the shallow half on
277        purpose — "did you keep a log and did you read it" — and the
278        model-judged half is what stops a forensic range being a grep
279        exercise.
280
281        ⚠️ **``offered`` is not the round count.** `defence_anchor`'s
282        rule through a third door: the attacker carrying these markers
283        is an LLM and therefore random, so a round it never landed in is
284        a round the *platform* failed to attack. Charging the team for
285        it would make the number a reading of our own uptime wearing
286        their name.
287
288        ``discovery_rate`` is ``None`` — never ``0.0`` — for an entrant
289        nothing reached.
290
291        ⚠️ **``armed`` reads whether the attacker carries an environment
292        at all**, which is a narrower question than "is there an
293        attacker": a reference-exploit match attacks every round and
294        mints no marker, because `build_attack_argv` passes no
295        environment and EXPLOIT007's guarantee rests on that. Every row
296        would then read ``offered: 0``, byte-identical to a field the
297        attacker reached and nobody traced.
298
299        ``reported`` annotates rather than decides: a team with no
300        report and a team whose report quotes nothing score the same,
301        and correctly — what differs is the repair.
302        """
303        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/forensics")
304        _raise_for_status(resp)
305        return AwdForensicsResponse.model_validate(resp.json())
306
307    def grade_forensics(self, competition_id: str) -> AdminTaskInfo:
308        """§4.3.1's model half: judge every entrant's write-up, now.
309
310        Returns the **task**, not the readings: this is one model call
311        per entrant, so a field of a dozen is a minute of work. Poll
312        ``client.admin.tasks.get(id)``, then read :meth:`forensics`,
313        whose rows carry ``analysis`` once it has run.
314
315        ⚠️ **A press rather than a cadence**, unlike the exercise
316        grader: a GAUNTLET report is drafted over days and graded on a
317        quiet window, while a match *ends* and the write-up is judged
318        once against the document it finished with.
319
320        ⚠️ **It spends a model call per entrant every press**, and the
321        task reports ``skipped`` rather than refusing when this
322        deployment has no gateway — ``forensics()``'s
323        ``analysis_configured`` is what says so before you press.
324
325        ⚠️ The verdicts are about **depth, never accuracy**: with no
326        answer key a model cannot say whether a stated root cause is
327        right, only whether the report states one specifically. A row
328        reading ``root_cause: specific`` is not the platform agreeing
329        with the defender.
330        """
331        resp = self._http.request(
332            "POST", f"/admin/competitions/{competition_id}/awd/forensics/grade"
333        )
334        _raise_for_status(resp)
335        return AdminTaskInfo.model_validate(resp.json())
336
337    def provision(self, competition_id: str) -> AdminTaskInfo:
338        """Start a gamebox for every team that lacks one.
339
340        Returns the **task**, not the boxes: a fleet launch is minutes
341        of work at event scale. Poll ``client.admin.tasks.get(id)`` (or
342        ``.logs``, which names every team that failed) for the outcome.
343        Idempotent — re-running skips the teams already holding a box.
344        """
345        resp = self._http.request("POST", f"/admin/competitions/{competition_id}/awd/provision")
346        _raise_for_status(resp)
347        return AdminTaskInfo.model_validate(resp.json())
348
349    def start_anyway(self, competition_id: str) -> AwdMatchInfo:
350        """Start the match although its arena was not measured whole (#1713).
351
352        The provisioning run checks every link between the arena's nodes
353        before kickoff; when that check is not ``whole`` the tick settles
354        no round until the arena is re-checked whole or somebody presses
355        this. Records who and when as an audit fact. Refused with 409
356        ``awd_kickoff_not_held`` when nothing is being held.
357        """
358        resp = self._http.request(
359            "POST", f"/admin/competitions/{competition_id}/awd/kickoff-override"
360        )
361        _raise_for_status(resp)
362        return AwdMatchInfo.model_validate(resp.json())
363
364    def teardown(self, competition_id: str) -> AdminTaskInfo:
365        """Stop every arena box of this match. Also a task.
366
367        Only this competition's ``lease="match"`` boxes: a player's own
368        on-demand instance is not part of the match, and no node is
369        wiped wholesale — the arena shares hosts with every other event
370        on the platform.
371        """
372        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/boxes")
373        _raise_for_status(resp)
374        return AdminTaskInfo.model_validate(resp.json())

The organiser's half of a match: cadence, gamebox, budgets.

⚠️ The round number is derived from the clock, not stored, so starts_at / tick_seconds / challenge_id are frozen once the match has started — editing one would retroactively renumber every round already settled and published. configure() answers 409 awd_match_locked naming the fields, rather than silently accepting a write that breaks the board.

There is deliberately no way to set last_tick_done: it is the tick loop's bookmark, and moving it makes the loop re-settle rounds the board has already published.

AdminAwdResource(http: ctfy.sdk.base.BaseHttpClient)
45    def __init__(self, http: BaseHttpClient) -> None:
46        self._http = http
def get(self, competition_id: str) -> ctfy.server.models.AwdMatchInfo:
48    def get(self, competition_id: str) -> AwdMatchInfo:
49        """This competition's match. 404 if it runs none.
50
51        ``current_tick`` and ``started`` are computed per read — asking
52        again a round later gives a different answer with no write in
53        between, which is the point.
54        """
55        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/match")
56        _raise_for_status(resp)
57        return AwdMatchInfo.model_validate(resp.json())

This competition's match. 404 if it runs none.

current_tick and started are computed per read — asking again a round later gives a different answer with no write in between, which is the point.

def configure( self, competition_id: str, *, tick_seconds: int, tick_count: int, challenge_id: str = '', starts_at: datetime.datetime | None = None, per_tick_budget: int | None = None, total_budget: int | None = None, attacker: str = 'none', reference_victim: bool = False, defence_mode: str = 'patch', shell_exposure: str = 'managed', open_shell_credential: str = 'owner_random', scoring_params: Mapping[str, float] | None = None) -> ctfy.server.models.AwdMatchInfo:
 59    def configure(
 60        self,
 61        competition_id: str,
 62        *,
 63        tick_seconds: int,
 64        tick_count: int,
 65        challenge_id: str = "",
 66        starts_at: datetime | None = None,
 67        per_tick_budget: int | None = None,
 68        total_budget: int | None = None,
 69        attacker: str = AwdAttacker.NONE.value,
 70        reference_victim: bool = False,
 71        defence_mode: str = AwdDefenceMode.PATCH.value,
 72        shell_exposure: str = AwdShellExposure.MANAGED.value,
 73        open_shell_credential: str = AwdOpenShellCredential.OWNER_RANDOM.value,
 74        scoring_params: Mapping[str, float] | None = None,
 75    ) -> AwdMatchInfo:
 76        """Create the match, or edit it. Idempotent on the competition.
 77
 78        ``challenge_id`` may be left empty while the match is being put
 79        together — provisioning is what refuses without a gamebox, and
 80        it refuses at the point an organiser can still act on it.
 81
 82        ⚠️ **This is a full replace, and every argument above is
 83        therefore a value rather than an option.** The route writes
 84        ``existing.model_copy(update=body)``, so an argument left at its
 85        default is *written*, not skipped: calling this to change the
 86        cadence of an armed match disarms the platform attacker and
 87        discards the organiser's scoring knobs. Read the row and pass it
 88        back if you mean "edit one field" — which is what ``ctfy-admin
 89        admin awd set`` does, and why it exists as more than a wrapper.
 90
 91        Kept as a replace on purpose: a caller that means *disarm this*
 92        has to be able to say so, and a library that quietly reads the
 93        current row before writing turns one request into a
 94        read-then-write race whose loser is somebody else's edit.
 95        """
 96        body: dict[str, object] = {
 97            "challenge_id": challenge_id,
 98            "starts_at": starts_at.isoformat() if starts_at else None,
 99            "tick_seconds": tick_seconds,
100            "tick_count": tick_count,
101            "per_tick_budget": per_tick_budget,
102            "total_budget": total_budget,
103            "attacker": attacker,
104            "reference_victim": reference_victim,
105            "defence_mode": defence_mode,
106            "shell_exposure": shell_exposure,
107            "open_shell_credential": open_shell_credential,
108            "scoring_params": dict(scoring_params or {}),
109        }
110        resp = self._http.request(
111            "PUT", f"/admin/competitions/{competition_id}/awd/match", json=body
112        )
113        _raise_for_status(resp)
114        return AwdMatchInfo.model_validate(resp.json())

Create the match, or edit it. Idempotent on the competition.

challenge_id may be left empty while the match is being put together — provisioning is what refuses without a gamebox, and it refuses at the point an organiser can still act on it.

⚠️ This is a full replace, and every argument above is therefore a value rather than an option. The route writes existing.model_copy(update=body), so an argument left at its default is written, not skipped: calling this to change the cadence of an armed match disarms the platform attacker and discards the organiser's scoring knobs. Read the row and pass it back if you mean "edit one field" — which is what ctfy-admin admin awd set does, and why it exists as more than a wrapper.

Kept as a replace on purpose: a caller that means disarm this has to be able to say so, and a library that quietly reads the current row before writing turns one request into a read-then-write race whose loser is somebody else's edit.

def delete(self, competition_id: str) -> None:
116    def delete(self, competition_id: str) -> None:
117        """Remove a match that never started and holds no boxes.
118
119        Refused once the first round has opened — its scores are
120        published and keyed by round, so the configuration they belong
121        to has to stay. Lowering ``tick_count`` is how a running match
122        is ended.
123        """
124        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/match")
125        _raise_for_status(resp)

Remove a match that never started and holds no boxes.

Refused once the first round has opened — its scores are published and keyed by round, so the configuration they belong to has to stay. Lowering tick_count is how a running match is ended.

def plan(self, competition_id: str) -> ctfy.server.models.AwdProvisionPlanInfo:
127    def plan(self, competition_id: str) -> AwdProvisionPlanInfo:
128        """What opening the arena would do, without doing it.
129
130        409 ``awd_provision_refused`` when the match cannot be
131        provisioned at all — no gamebox, no teams, or a match that has
132        already started (§4.5 fixes the box supply at kickoff).
133        """
134        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/plan")
135        _raise_for_status(resp)
136        return AwdProvisionPlanInfo.model_validate(resp.json())

What opening the arena would do, without doing it.

409 awd_provision_refused when the match cannot be provisioned at all — no gamebox, no teams, or a match that has already started (§4.5 fixes the box supply at kickoff).

def arena_peers( self, competition_id: str) -> ctfy.server.models.AwdArenaPeersResponse:
138    def arena_peers(self, competition_id: str) -> AwdArenaPeersResponse:
139        """Who is on this match's arena, and whether their tunnel carries.
140
141        One row per person: the worker their tunnel terminates on, the
142        address they hold, and what the last round's probe found — no
143        handshake, a handshake with no ping, or small packets through and
144        full-size ones not, which are three different repairs on three
145        different machines.
146
147        ``link`` is ``None`` when no round has probed that peer yet,
148        which is deliberately not the same as a peer that is down.
149
150        Carries no config and no key: knowing *that* somebody is
151        connected is the job, and the private half is stored nowhere.
152        """
153        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/vpn-peers")
154        _raise_for_status(resp)
155        return AwdArenaPeersResponse.model_validate(resp.json())

Who is on this match's arena, and whether their tunnel carries.

One row per person: the worker their tunnel terminates on, the address they hold, and what the last round's probe found — no handshake, a handshake with no ping, or small packets through and full-size ones not, which are three different repairs on three different machines.

link is None when no round has probed that peer yet, which is deliberately not the same as a peer that is down.

Carries no config and no key: knowing that somebody is connected is the job, and the private half is stored nowhere.

def corroboration( self, competition_id: str, tick: int) -> ctfy.server.models.AwdCorroborationResponse:
157    def corroboration(self, competition_id: str, tick: int) -> AwdCorroborationResponse:
158        """Which of a round's captures the victims' own traffic backs up.
159
160        A flag is bound to ``(victim, service, tick)`` and not to whoever
161        went and got it, so passing one between teams scores — a
162        structural hole in AWD. What a platform with packet capture adds
163        is cost: an attacker who really took the flag was on the box, so
164        their arena address is in that box's capture.
165
166        **Reports, never rules.** No score moves and no flag is refused;
167        the submission path does not call this. ``verdict`` is
168        three-valued and only ``absent`` says anything about the
169        attacker — ``unreadable`` is every way of not finding somebody
170        that is not evidence *about* them (no sidecar on their box, a
171        box that recorded nothing because its service is not HTTP, or an
172        attacking team with no arena address on record at all).
173
174        ``totals`` carries every verdict at zero, so a round that is
175        entirely unreadable says so rather than reading as "no
176        findings".
177        """
178        resp = self._http.request(
179            "GET",
180            f"/admin/competitions/{competition_id}/awd/corroboration",
181            params={"tick": tick},
182        )
183        _raise_for_status(resp)
184        return AwdCorroborationResponse.model_validate(resp.json())

Which of a round's captures the victims' own traffic backs up.

A flag is bound to (victim, service, tick) and not to whoever went and got it, so passing one between teams scores — a structural hole in AWD. What a platform with packet capture adds is cost: an attacker who really took the flag was on the box, so their arena address is in that box's capture.

Reports, never rules. No score moves and no flag is refused; the submission path does not call this. verdict is three-valued and only absent says anything about the attacker — unreadable is every way of not finding somebody that is not evidence about them (no sidecar on their box, a box that recorded nothing because its service is not HTTP, or an attacking team with no arena address on record at all).

totals carries every verdict at zero, so a round that is entirely unreadable says so rather than reading as "no findings".

def defence_anchor( self, competition_id: str) -> ctfy.server.models.AwdDefenceAnchorResponse:
186    def defence_anchor(self, competition_id: str) -> AwdDefenceAnchorResponse:
187        """Who blocked the reference exploit, and over how many rounds.
188
189        A model-vs-model ranking is **relative** — a weak field flatters
190        everybody on it — so it does not compare across matches, which
191        for an evaluation product is the whole problem. The reference
192        attacker is the fixed half: every entrant, in every match, meets
193        the same ``solution/exploit.py``, so "held N of the M rounds it
194        ran" carries the same meaning everywhere.
195
196        ⚠️ **M is not the round count.** ``held`` is counted only over
197        rounds in which the exploit actually executed: a round the
198        platform never attacked and a round the defender held leave the
199        same absence, so a scale derived from captures alone credits an
200        entrant for every round the platform failed to attack them.
201        ``not_attempted`` is how much of the match went unmeasured, and
202        it is the platform's fault rather than theirs.
203
204        ``hold_rate`` is ``None`` — never ``0.0`` — for an entrant with
205        no attempted rounds: a zero would assert they were breached
206        every round.
207
208        ``armed`` is ``False`` for a match that runs no reference
209        attacker at all, which is a different thing from an attacker
210        that found nothing.
211        """
212        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/anchor")
213        _raise_for_status(resp)
214        return AwdDefenceAnchorResponse.model_validate(resp.json())

Who blocked the reference exploit, and over how many rounds.

A model-vs-model ranking is relative — a weak field flatters everybody on it — so it does not compare across matches, which for an evaluation product is the whole problem. The reference attacker is the fixed half: every entrant, in every match, meets the same solution/exploit.py, so "held N of the M rounds it ran" carries the same meaning everywhere.

⚠️ M is not the round count. held is counted only over rounds in which the exploit actually executed: a round the platform never attacked and a round the defender held leave the same absence, so a scale derived from captures alone credits an entrant for every round the platform failed to attack them. not_attempted is how much of the match went unmeasured, and it is the platform's fault rather than theirs.

hold_rate is None — never 0.0 — for an entrant with no attempted rounds: a zero would assert they were breached every round.

armed is False for a match that runs no reference attacker at all, which is a different thing from an attacker that found nothing.

def sla_history( self, competition_id: str, *, team_id: str = '') -> ctfy.server.models.AwdSlaHistoryResponse:
216    def sla_history(self, competition_id: str, *, team_id: str = "") -> AwdSlaHistoryResponse:
217        """When in each round this match's SLA probes actually landed.
218
219        A check row records *which* round and, until recently, nothing
220        about *when in it* — so "was my box probed before I had a chance
221        to repair it", the question a disputed deduction turns on, could
222        only be answered by reading the tick loop. Each row here carries
223        the seconds from its own round's opening, and ``spread_s`` is how
224        much later the last box was asked than the first.
225
226        ``offset_s`` is ``None`` for a row written before the column
227        existed, and for a match with no kickoff to measure from. It is
228        never ``0.0`` for those: zero means the probe landed on the
229        round's opening second.
230        """
231        params: dict[str, str] = {}
232        if team_id:
233            params["team_id"] = team_id
234        resp = self._http.request(
235            "GET", f"/admin/competitions/{competition_id}/awd/sla-history", params=params or None
236        )
237        return AwdSlaHistoryResponse.model_validate(resp.json())

When in each round this match's SLA probes actually landed.

A check row records which round and, until recently, nothing about when in it — so "was my box probed before I had a chance to repair it", the question a disputed deduction turns on, could only be answered by reading the tick loop. Each row here carries the seconds from its own round's opening, and spread_s is how much later the last box was asked than the first.

offset_s is None for a row written before the column existed, and for a match with no kickoff to measure from. It is never 0.0 for those: zero means the probe landed on the round's opening second.

def attack_anchor( self, competition_id: str) -> ctfy.server.models.AwdAttackAnchorResponse:
239    def attack_anchor(self, competition_id: str) -> AwdAttackAnchorResponse:
240        """Who broke the reference victim, and over how many rounds.
241
242        The mirror of :meth:`defence_anchor`. That one fixes the
243        **attacker** every entrant meets; this one fixes the
244        **victim** — §16.6's free target, one unpatched box owned by
245        nobody and never defended — so "broke a known-vulnerable box in
246        N of the M rounds it was up" carries the same meaning in every
247        match, where a place on the relative board is a statement about
248        the field an entrant happened to draw.
249
250        ⚠️ **M is the free box's own SLA record, not the round count.**
251        Nobody can break a box that was down, so debiting every entrant
252        for a round the instrument spent broken makes the number a
253        reading of our uptime rather than their attack. ``offered`` is
254        that denominator and is **match-wide**: every row carries the
255        same value, which is what makes two entrants comparable.
256
257        ``breach_rate`` is ``None`` — never ``0.0`` — when nothing was
258        offered, and ``first_breach_tick`` is ``None`` — never ``-1``
259        and never ``0``, since round 0 is a real round — for an entrant
260        who never broke it.
261
262        ``armed`` is ``False`` for a match that runs no reference victim
263        at all, which is a different thing from a free box nobody broke.
264        """
265        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/attack-anchor")
266        _raise_for_status(resp)
267        return AwdAttackAnchorResponse.model_validate(resp.json())

Who broke the reference victim, and over how many rounds.

The mirror of defence_anchor(). That one fixes the attacker every entrant meets; this one fixes the victim — §16.6's free target, one unpatched box owned by nobody and never defended — so "broke a known-vulnerable box in N of the M rounds it was up" carries the same meaning in every match, where a place on the relative board is a statement about the field an entrant happened to draw.

⚠️ M is the free box's own SLA record, not the round count. Nobody can break a box that was down, so debiting every entrant for a round the instrument spent broken makes the number a reading of our uptime rather than their attack. offered is that denominator and is match-wide: every row carries the same value, which is what makes two entrants comparable.

breach_rate is None — never 0.0 — when nothing was offered, and first_breach_tick is None — never -1 and never 0, since round 0 is a real round — for an entrant who never broke it.

armed is False for a match that runs no reference victim at all, which is a different thing from a free box nobody broke.

def forensics(self, competition_id: str) -> ctfy.server.models.AwdForensicsResponse:
269    def forensics(self, competition_id: str) -> AwdForensicsResponse:
270        """§4.3.1's machine half: who found the attacker in their own box.
271
272        The platform's agent attacker carries a marker per ``(team,
273        round)`` into every gamebox. A defender who instrumented their
274        box can locate where that round's attacker touched it and quote
275        the value back in their engagement report; this is how much of
276        what reached them they found. It is the shallow half on
277        purpose — "did you keep a log and did you read it" — and the
278        model-judged half is what stops a forensic range being a grep
279        exercise.
280
281        ⚠️ **``offered`` is not the round count.** `defence_anchor`'s
282        rule through a third door: the attacker carrying these markers
283        is an LLM and therefore random, so a round it never landed in is
284        a round the *platform* failed to attack. Charging the team for
285        it would make the number a reading of our own uptime wearing
286        their name.
287
288        ``discovery_rate`` is ``None`` — never ``0.0`` — for an entrant
289        nothing reached.
290
291        ⚠️ **``armed`` reads whether the attacker carries an environment
292        at all**, which is a narrower question than "is there an
293        attacker": a reference-exploit match attacks every round and
294        mints no marker, because `build_attack_argv` passes no
295        environment and EXPLOIT007's guarantee rests on that. Every row
296        would then read ``offered: 0``, byte-identical to a field the
297        attacker reached and nobody traced.
298
299        ``reported`` annotates rather than decides: a team with no
300        report and a team whose report quotes nothing score the same,
301        and correctly — what differs is the repair.
302        """
303        resp = self._http.request("GET", f"/admin/competitions/{competition_id}/awd/forensics")
304        _raise_for_status(resp)
305        return AwdForensicsResponse.model_validate(resp.json())

§4.3.1's machine half: who found the attacker in their own box.

The platform's agent attacker carries a marker per (team, round) into every gamebox. A defender who instrumented their box can locate where that round's attacker touched it and quote the value back in their engagement report; this is how much of what reached them they found. It is the shallow half on purpose — "did you keep a log and did you read it" — and the model-judged half is what stops a forensic range being a grep exercise.

⚠️ offered is not the round count. defence_anchor's rule through a third door: the attacker carrying these markers is an LLM and therefore random, so a round it never landed in is a round the platform failed to attack. Charging the team for it would make the number a reading of our own uptime wearing their name.

discovery_rate is None — never 0.0 — for an entrant nothing reached.

⚠️ armed reads whether the attacker carries an environment at all, which is a narrower question than "is there an attacker": a reference-exploit match attacks every round and mints no marker, because build_attack_argv passes no environment and EXPLOIT007's guarantee rests on that. Every row would then read offered: 0, byte-identical to a field the attacker reached and nobody traced.

reported annotates rather than decides: a team with no report and a team whose report quotes nothing score the same, and correctly — what differs is the repair.

def grade_forensics(self, competition_id: str) -> ctfy.server.models.AdminTaskInfo:
307    def grade_forensics(self, competition_id: str) -> AdminTaskInfo:
308        """§4.3.1's model half: judge every entrant's write-up, now.
309
310        Returns the **task**, not the readings: this is one model call
311        per entrant, so a field of a dozen is a minute of work. Poll
312        ``client.admin.tasks.get(id)``, then read :meth:`forensics`,
313        whose rows carry ``analysis`` once it has run.
314
315        ⚠️ **A press rather than a cadence**, unlike the exercise
316        grader: a GAUNTLET report is drafted over days and graded on a
317        quiet window, while a match *ends* and the write-up is judged
318        once against the document it finished with.
319
320        ⚠️ **It spends a model call per entrant every press**, and the
321        task reports ``skipped`` rather than refusing when this
322        deployment has no gateway — ``forensics()``'s
323        ``analysis_configured`` is what says so before you press.
324
325        ⚠️ The verdicts are about **depth, never accuracy**: with no
326        answer key a model cannot say whether a stated root cause is
327        right, only whether the report states one specifically. A row
328        reading ``root_cause: specific`` is not the platform agreeing
329        with the defender.
330        """
331        resp = self._http.request(
332            "POST", f"/admin/competitions/{competition_id}/awd/forensics/grade"
333        )
334        _raise_for_status(resp)
335        return AdminTaskInfo.model_validate(resp.json())

§4.3.1's model half: judge every entrant's write-up, now.

Returns the task, not the readings: this is one model call per entrant, so a field of a dozen is a minute of work. Poll client.admin.tasks.get(id), then read forensics(), whose rows carry analysis once it has run.

⚠️ A press rather than a cadence, unlike the exercise grader: a GAUNTLET report is drafted over days and graded on a quiet window, while a match ends and the write-up is judged once against the document it finished with.

⚠️ It spends a model call per entrant every press, and the task reports skipped rather than refusing when this deployment has no gateway — forensics()'s analysis_configured is what says so before you press.

⚠️ The verdicts are about depth, never accuracy: with no answer key a model cannot say whether a stated root cause is right, only whether the report states one specifically. A row reading root_cause: specific is not the platform agreeing with the defender.

def provision(self, competition_id: str) -> ctfy.server.models.AdminTaskInfo:
337    def provision(self, competition_id: str) -> AdminTaskInfo:
338        """Start a gamebox for every team that lacks one.
339
340        Returns the **task**, not the boxes: a fleet launch is minutes
341        of work at event scale. Poll ``client.admin.tasks.get(id)`` (or
342        ``.logs``, which names every team that failed) for the outcome.
343        Idempotent — re-running skips the teams already holding a box.
344        """
345        resp = self._http.request("POST", f"/admin/competitions/{competition_id}/awd/provision")
346        _raise_for_status(resp)
347        return AdminTaskInfo.model_validate(resp.json())

Start a gamebox for every team that lacks one.

Returns the task, not the boxes: a fleet launch is minutes of work at event scale. Poll client.admin.tasks.get(id) (or .logs, which names every team that failed) for the outcome. Idempotent — re-running skips the teams already holding a box.

def start_anyway(self, competition_id: str) -> ctfy.server.models.AwdMatchInfo:
349    def start_anyway(self, competition_id: str) -> AwdMatchInfo:
350        """Start the match although its arena was not measured whole (#1713).
351
352        The provisioning run checks every link between the arena's nodes
353        before kickoff; when that check is not ``whole`` the tick settles
354        no round until the arena is re-checked whole or somebody presses
355        this. Records who and when as an audit fact. Refused with 409
356        ``awd_kickoff_not_held`` when nothing is being held.
357        """
358        resp = self._http.request(
359            "POST", f"/admin/competitions/{competition_id}/awd/kickoff-override"
360        )
361        _raise_for_status(resp)
362        return AwdMatchInfo.model_validate(resp.json())

Start the match although its arena was not measured whole (#1713).

The provisioning run checks every link between the arena's nodes before kickoff; when that check is not whole the tick settles no round until the arena is re-checked whole or somebody presses this. Records who and when as an audit fact. Refused with 409 awd_kickoff_not_held when nothing is being held.

def teardown(self, competition_id: str) -> ctfy.server.models.AdminTaskInfo:
364    def teardown(self, competition_id: str) -> AdminTaskInfo:
365        """Stop every arena box of this match. Also a task.
366
367        Only this competition's ``lease="match"`` boxes: a player's own
368        on-demand instance is not part of the match, and no node is
369        wiped wholesale — the arena shares hosts with every other event
370        on the platform.
371        """
372        resp = self._http.request("DELETE", f"/admin/competitions/{competition_id}/awd/boxes")
373        _raise_for_status(resp)
374        return AdminTaskInfo.model_validate(resp.json())

Stop every arena box of this match. Also a task.

Only this competition's lease="match" boxes: a player's own on-demand instance is not part of the match, and no node is wiped wholesale — the arena shares hosts with every other event on the platform.