ctfy.server.models

Public wire-model package — re-exports for back-compat.

   1"""Public wire-model package — re-exports for back-compat."""
   2
   3from __future__ import annotations
   4
   5from ctfy.server.models.achievement import (
   6    AchievementCatalogEntry,
   7    AchievementCopy,
   8    AchievementProgress,
   9    AchievementSummary,
  10    EasterEggClaim,
  11    MyAchievementsResponse,
  12    RecentUnlock,
  13    StarGazerVerifyResponse,
  14    TeamAchievement,
  15)
  16from ctfy.server.models.activity import (
  17    Activity,
  18    ActivityTimeseries,
  19    ActivityTimeseriesBucket,
  20)
  21from ctfy.server.models.admin import (
  22    AccountKeyRow,
  23    AdminArtifactStorageFlag,
  24    AdminChallengeLastError,
  25    AdminChallengeLatencyBucket,
  26    AdminChallengeStatsRow,
  27    AdminChallengeTimeseries,
  28    AdminChallengeTimeseriesBucket,
  29    AdminHealthFlags,
  30    AdminOverview,
  31    AdminOverviewCounts,
  32    AdminRecentError,
  33    AdminSilentChallenge,
  34    AdminSolveCell,
  35    AdminSolveMatrix,
  36    AdminSolveMatrixChallenge,
  37    AdminSolveMatrixTeam,
  38    AdminStuckInstance,
  39    AdminTaskInfo,
  40    AdminTaskListResponse,
  41    AdminTaskLogLine,
  42    AdminTaskLogsResponse,
  43    AdminTaskSubmitRequest,
  44    AdminTimeseries,
  45    AdminTimeseriesBucket,
  46    AdminTimeseriesMetric,
  47    AdminTrafficInstanceRow,
  48    AdminTrafficSummary,
  49    AdminTrafficTeamRow,
  50    AdminUnhealthyNode,
  51    DeceptionStats,
  52    DeploymentConfig,
  53    LlmBudgetPage,
  54    LlmBudgetResetInfo,
  55    LlmBudgetResetRequest,
  56    LlmBudgetRow,
  57    LlmProviderCreate,
  58    LlmProviderInfo,
  59    LlmProviderPage,
  60    LlmProviderProbe,
  61    PlatformSettingInfo,
  62    PlatformSettingPatch,
  63    QuestionAttemptResetInfo,
  64    QuestionAttemptResetRequest,
  65    ScheduledJobInfo,
  66    ScheduledJobPatch,
  67    ShellRecordingInfo,
  68)
  69from ctfy.server.models.announcement import (
  70    AnnouncementCreate,
  71    AnnouncementInfo,
  72    AnnouncementUpdate,
  73)
  74from ctfy.server.models.auth import (
  75    AddEmailRequest,
  76    AdminTokenRevokeResponse,
  77    AuthTokenResponse,
  78    CreateFineGrainedTokenRequest,
  79    CreateFineGrainedTokenResponse,
  80    DeviceApproveRequest,
  81    DeviceCodeResponse,
  82    DeviceInfoResponse,
  83    DeviceTokenRequest,
  84    DeviceTokenResponse,
  85    EmailCodeAuthInfo,
  86    EmailVerificationSent,
  87    EmailVerificationStatus,
  88    LinkedIdentity,
  89    LinkStartResponse,
  90    LoginRequest,
  91    OAuthProviderInfo,
  92    PasswordAuthInfo,
  93    ProvidersResponse,
  94    RegisterRequest,
  95    ScopeCatalogEntry,
  96    SetPasswordRequest,
  97    TokenInfo,
  98    TokenLifetimeRequest,
  99    TokenScopesResponse,
 100    TokenScopeUpdate,
 101    TokenTemplatesResponse,
 102    TokenTraceLink,
 103    TokenTraceRequest,
 104    TokenTraceResponse,
 105    UserEmailInfo,
 106    VerifyAddedEmailRequest,
 107    VerifyEmailRequest,
 108)
 109from ctfy.server.models.authoring import (
 110    AdminAuthoredChallengeDetail,
 111    AdminAuthoredChallengeRow,
 112    AdminAuthoredRevisionRow,
 113    AuditFinding,
 114    AuthoredAuditReport,
 115    AuthoredAutoSmoke,
 116    AuthoredBuildCapability,
 117    AuthoredChallengeFileBody,
 118    AuthoredChallengeFileEntry,
 119    AuthoredChallengeFileRequest,
 120    AuthoredChallengeInfo,
 121    AuthoredChallengeReviewRequest,
 122    AuthoredChallengeRow,
 123    AuthoredCurationElsewhere,
 124    AuthoredCurationState,
 125    AuthoredDeclarations,
 126    AuthoredGraduationInfo,
 127    AuthoredQualityFlag,
 128    AuthoredReceptionRow,
 129    AuthoredReceptionState,
 130    AuthoredRevisionDiff,
 131    AuthoredRevisionFileChange,
 132    AuthoredRevisionInfo,
 133    AuthoredRevisionRow,
 134    AuthoringDivision,
 135    AuthoringReceptionInfo,
 136    AuthoringTemplateFile,
 137    AuthoringTemplateManifest,
 138    AuthorReputation,
 139    ContributorRow,
 140    CreditedChallengeRow,
 141    ReactionCount,
 142)
 143from ctfy.server.models.awd import (
 144    MAX_FLAGS_PER_SUBMISSION,
 145    AwdArenaGate,
 146    AwdArenaGateLink,
 147    AwdArenaLink,
 148    AwdArenaPeerRow,
 149    AwdArenaPeersResponse,
 150    AwdAttackAnchorResponse,
 151    AwdAttackAnchorRow,
 152    AwdAttackMatrixResponse,
 153    AwdAttackMatrixRow,
 154    AwdBoardEntry,
 155    AwdBoardResponse,
 156    AwdCaptureCorroborationRow,
 157    AwdCorroborationResponse,
 158    AwdDefenceAnchorResponse,
 159    AwdDefenceAnchorRow,
 160    AwdDefenceTimelineCell,
 161    AwdDefenceTimelineResponse,
 162    AwdDefenceTimelineRow,
 163    AwdEventRow,
 164    AwdEventsResponse,
 165    AwdFlagResult,
 166    AwdFlagSubmitRequest,
 167    AwdFlagSubmitResponse,
 168    AwdForensicAnalysis,
 169    AwdForensicsResponse,
 170    AwdForensicsRow,
 171    AwdMatchConfig,
 172    AwdMatchInfo,
 173    AwdMatchStatus,
 174    AwdProvisionPlanInfo,
 175    AwdRoundHistoryResponse,
 176    AwdRoundPoint,
 177    AwdRoundSeries,
 178    AwdSlaHistoryResponse,
 179    AwdSlaProbeRow,
 180    AwdTarget,
 181    AwdTargetsResponse,
 182    AwdVpnPeer,
 183    AwdVpnRequest,
 184    AwdVpnStatus,
 185)
 186from ctfy.server.models.challenge import (
 187    AdminChallengeQuestions,
 188    AdminPatchSubmissionDetail,
 189    AdminPatchSubmissionInfo,
 190    AdminPatchSubmissionPage,
 191    AgentEntrantInfo,
 192    AttachmentList,
 193    BlueprintHostInfo,
 194    BlueprintMilestoneInfo,
 195    BuildPushScopeRequest,
 196    ChallengeBlueprint,
 197    ChallengeBuildKickoffNodeResult,
 198    ChallengeBuildKickoffResponse,
 199    ChallengeBuildNodeState,
 200    ChallengeBuildStateResponse,
 201    ChallengeBuildStateRow,
 202    ChallengeFacetCount,
 203    ChallengeFacets,
 204    ChallengeFlagStats,
 205    ChallengeInfo,
 206    ChallengePullKickoffNodeResult,
 207    ChallengePullKickoffResponse,
 208    ChallengePullNodeState,
 209    ChallengePullStateResponse,
 210    ChallengePullStateRow,
 211    ChallengeRescanNodeResult,
 212    ChallengeRescanResult,
 213    ChallengeSolveAttempt,
 214    ChallengeSolveAttemptsResponse,
 215    ChallengeStats,
 216    ChallengeTeamSolveSummary,
 217    ChallengeValuePoint,
 218    CompetitionChallengeBreakdown,
 219    CompetitionChallengeRow,
 220    CompetitionScoreDistribution,
 221    CompetitionScoreHistory,
 222    DefenceInfo,
 223    DefenceLiveFile,
 224    DefenceMatrixResponse,
 225    DefenceMatrixRow,
 226    DefenceSource,
 227    MilestoneProgress,
 228    MySolveSummary,
 229    PatchStepInfo,
 230    PatchSubmissionCreate,
 231    PatchSubmissionInfo,
 232    QaChallengeView,
 233    QaSubmissionCreate,
 234    QuestionPublicInfo,
 235    RangeProgress,
 236    RangeProgressRow,
 237    RangeReplay,
 238    RangeReplayFrame,
 239    RangeReplayPosition,
 240    ScoreboardEntry,
 241    ScoreBucket,
 242    ScoreHistoryPoint,
 243    ScoreHistorySeries,
 244    SubmissionCreate,
 245    SubmissionResponse,
 246    UserScoreboardEntry,
 247)
 248from ctfy.server.models.changelog import (
 249    AdminChangelogEntryInfo,
 250    ChangelogAnnounceRequest,
 251    ChangelogAnnounceResponse,
 252    ChangelogEntryCreate,
 253    ChangelogEntryInfo,
 254    ChangelogEntryUpdate,
 255    ChangelogImportEntry,
 256    ChangelogImportRequest,
 257    ChangelogImportResponse,
 258    ChangelogLatestResponse,
 259    ChangelogListResponse,
 260    ChangelogSeenResponse,
 261)
 262from ctfy.server.models.competition import (
 263    AdminCompetitionAttachmentsResponse,
 264    AuthoringReviewerInfo,
 265    AuthoringScaleInfo,
 266    AwdClockBrief,
 267    BoardCriterion,
 268    BulkInviteRequest,
 269    BulkInviteResponse,
 270    BulkInviteResultRow,
 271    CompetitionAdminInfo,
 272    CompetitionApplicationInfo,
 273    CompetitionApplicationRequest,
 274    CompetitionApplicationReviewRequest,
 275    CompetitionApplicationRow,
 276    CompetitionAttachmentInfo,
 277    CompetitionAttachmentsResponse,
 278    CompetitionAttachmentUpdate,
 279    CompetitionCreate,
 280    CompetitionDetail,
 281    CompetitionFormatPreset,
 282    CompetitionInfo,
 283    CompetitionInviteInfo,
 284    CompetitionMembershipInfo,
 285    CompetitionRegistrationInfo,
 286    CompetitionSessionInfo,
 287    CompetitionUpdate,
 288    GrantAuthoringReviewerRequest,
 289    GrantCompetitionAdminRequest,
 290    VirtualGhostBoard,
 291    VirtualGhostRow,
 292)
 293from ctfy.server.models.eval import (
 294    RUN_ARTIFACT_BLOCKED,
 295    RUN_TRAFFIC_BLOCKED,
 296    RUN_TRANSCRIPT_BLOCKED,
 297    RUN_WALL_BLOCKED,
 298    RUN_WRITEUP_BLOCKED,
 299    TRANSCRIPT_REFUSALS,
 300    WALL_REFUSALS,
 301    AdminHarnessRow,
 302    AgentActionRow,
 303    AgentActionsPage,
 304    AgentPulseBeat,
 305    AgentPulseLane,
 306    AgentPulsePage,
 307    EvalAllowanceGrant,
 308    EvalAllowanceRow,
 309    EvalArtifactBucket,
 310    EvalArtifactReport,
 311    EvalEntryRequest,
 312    EvalLeaderboard,
 313    EvalRunCoverage,
 314    EvalTrendPoint,
 315    HarnessModelCell,
 316    HarnessStanding,
 317    HarnessTags,
 318    HarnessUpdate,
 319    LeaderboardCell,
 320    LlmCallBody,
 321    LlmCallPage,
 322    LlmCallRow,
 323    LlmModelOption,
 324    LlmProviderChoice,
 325    ModelStanding,
 326    MyEvalRunPage,
 327    MyEvalRunRow,
 328    MyLlmKey,
 329    MyLlmKeyCreate,
 330    MyLlmKeyProbe,
 331    PublicTranscriptBlock,
 332    PublicTranscriptResponse,
 333    PublicTranscriptTurn,
 334    QuestionCoverageRow,
 335    RegistryPushTarget,
 336    RegistryQuotaInfo,
 337    RegistryUsageReport,
 338    RegistryUsageRow,
 339    RunTranscriptRequest,
 340    RunWallRequest,
 341    RunWriteup,
 342    VendorStandardSet,
 343    VendorStanding,
 344)
 345from ctfy.server.models.feedback import (
 346    AdminFeedbackRow,
 347    FeedbackStats,
 348    MyReactionsResponse,
 349)
 350from ctfy.server.models.gauntlet import (
 351    GauntletBuildCost,
 352    GauntletBuildStep,
 353    GauntletLibraryResponse,
 354    GauntletModuleInfo,
 355    GauntletPlanRequest,
 356    GauntletPlanResponse,
 357    GauntletPreset,
 358    GauntletPreviewRequest,
 359    GauntletPreviewResponse,
 360    GauntletRangeCreate,
 361    GauntletRangeCreated,
 362    GauntletRangeInfo,
 363    GauntletRangeListResponse,
 364    GauntletShortfall,
 365)
 366from ctfy.server.models.instance import (
 367    AnswerTraceHit,
 368    AnswerTraceRequest,
 369    AnswerTraceResponse,
 370    ArchiveStoreReport,
 371    CheckAnswerRequest,
 372    CheckAnswerResponse,
 373    InstanceAnswersResponse,
 374    InstanceInfo,
 375    InstanceQuestionInfo,
 376    InstanceRecordArtifacts,
 377    InstanceRecordDetail,
 378    InstanceRecordInfo,
 379    InstanceRecordInfoDetail,
 380    InstanceStatusResponse,
 381    InstanceStorageReport,
 382    InstanceTrafficPage,
 383    InstanceTrafficWitnesses,
 384    NodeCaptureStoreRow,
 385    PlayerShellCreate,
 386    PlayerShellTicket,
 387    RenewResponse,
 388    RevealedAnswer,
 389    SshCredential,
 390    SshExchangeRequest,
 391    StartRequest,
 392    StartResponse,
 393    StopResponse,
 394    TrafficWitness,
 395    VerifyAnswerRequest,
 396    VerifyAnswerResponse,
 397)
 398from ctfy.server.models.institution import (
 399    AffiliationInfo,
 400    AffiliationSelection,
 401    InstitutionCreate,
 402    InstitutionInfo,
 403    InstitutionPage,
 404    InstitutionSuggestion,
 405    InstitutionSuggestionPage,
 406)
 407from ctfy.server.models.meta import (
 408    ErrorResponse,
 409    HealthResponse,
 410    MetaAgents,
 411    MetaAuthoredArchive,
 412    MetaChallenges,
 413    MetaContribute,
 414    MetaEvents,
 415    MetaInstanceTtl,
 416    MetaLlm,
 417    MetaMine,
 418    MetaOps,
 419    MetaPlatform,
 420    MetaResponse,
 421)
 422from ctfy.server.models.node import (
 423    ClusterInfo,
 424    CreateInviteRequest,
 425    CreateInviteResponse,
 426    ImageConsistencyRow,
 427    NodeAuthoredPush,
 428    NodeAuthoredResult,
 429    NodeAuthoredSmokePush,
 430    NodeAuthoredSmokeResult,
 431    NodeAuthoredSyncLine,
 432    NodeCaptureStoreReport,
 433    NodeHeartbeat,
 434    NodeHeartbeatResponse,
 435    NodeInfo,
 436    NodeInviteInfo,
 437    NodePatch,
 438    NodeRangePush,
 439    NodeRangeResult,
 440    NodeRangeSyncLine,
 441    NodeRangeVerifyPush,
 442    NodeRangeVerifyResult,
 443    NodeRegister,
 444    NodeRegisterResponse,
 445    NodeRegistryTicket,
 446    NodeRegistryTicketRequest,
 447)
 448from ctfy.server.models.notification import (
 449    EmailSuppressionCreate,
 450    EmailSuppressionInfo,
 451    EmailSuppressionListResponse,
 452    EmailTemplateInfo,
 453    EmailTemplateListResponse,
 454    NotificationCategoryInfo,
 455    NotificationChannelCell,
 456    NotificationPreferencesResponse,
 457    NotificationPreferenceUpdate,
 458    TestEmailRequest,
 459    TestEmailResult,
 460)
 461from ctfy.server.models.pagination import (
 462    BigLimitOffsetPage,
 463    BigLimitOffsetPageOf100,
 464    LimitOffsetPageUpTo200,
 465    LimitOffsetPageUpTo500,
 466)
 467from ctfy.server.models.records import (
 468    CompetitionRecords,
 469    RecordAuthoredChallenge,
 470    RecordEvalRun,
 471    RecordSolve,
 472    RecordSubmission,
 473    RecordTeam,
 474)
 475from ctfy.server.models.registration import (
 476    MyRegistrationResponse,
 477    RegistrationFieldSpec,
 478    RegistrationFormSpec,
 479    RegistrationRosterRow,
 480    RegistrationSubmitRequest,
 481    RegistrationSummary,
 482    RegistrationTeamRow,
 483    ReviewDecisionRequest,
 484    TeamLogoUploadResponse,
 485)
 486from ctfy.server.models.registry import (
 487    RegistryGcStatus,
 488    RegistryManifestRow,
 489    RegistryReclaimItem,
 490    RegistryReclaimPlan,
 491    RegistryReclaimRequest,
 492    RegistryRepo,
 493    RegistryRepoList,
 494    RegistryTagList,
 495)
 496from ctfy.server.models.report import (
 497    MyReportRow,
 498    MyReportsResponse,
 499    ReportAttachmentInfo,
 500    ReportAttachmentsResponse,
 501    ReportClaimRow,
 502    ReportClaimsPage,
 503    ReportGradeInfo,
 504    ReportGradesResponse,
 505    ReportGradeSummary,
 506    ReportInfo,
 507    ReportPipeline,
 508    ReportPipelineEvent,
 509    ReportRosterResponse,
 510    ReportRosterRow,
 511    ReportSaveRequest,
 512    ReportSaveResponse,
 513    ReportStanding,
 514    ReportVersionsResponse,
 515)
 516from ctfy.server.models.series import (
 517    CompetitionSeriesConfig,
 518    CompetitionSeriesInfo,
 519    SeriesOccurrencePreview,
 520    SeriesPreview,
 521)
 522from ctfy.server.models.team import (
 523    AdminTeamRow,
 524    CompetitionRoster,
 525    CompetitionRosterTeam,
 526    InboxAnnouncement,
 527    InboxCaptainRequest,
 528    InboxCompetitionInvite,
 529    InboxIncomingInvite,
 530    InboxOutgoingRequest,
 531    InboxResponse,
 532    TeamAffiliationSelection,
 533    TeamCaptainRequest,
 534    TeamDetail,
 535    TeamDisqualifyRequest,
 536    TeamInfo,
 537    TeamInviteInfo,
 538    TeamMemberInfo,
 539    TeamStandingInfo,
 540)
 541from ctfy.server.models.user import (
 542    PROFILE_VISIBILITY_KEYS,
 543    AccountDeletionAccepted,
 544    AdminUserInfo,
 545    CalendarBucket,
 546    DeleteMeRequest,
 547    DifficultyStat,
 548    MeResponse,
 549    OnboardingIntentRequest,
 550    ProfilePatchRequest,
 551    ProfileStats,
 552    ReauthenticateRequest,
 553    ReauthenticationOpened,
 554    TagStat,
 555    TrendPoint,
 556    UpdateUserRoleRequest,
 557    UpdateUserStandingsRequest,
 558    UserInfo,
 559    UserSolveTrend,
 560)
 561
 562__all__ = [
 563    "MAX_FLAGS_PER_SUBMISSION",
 564    "PROFILE_VISIBILITY_KEYS",
 565    "RUN_ARTIFACT_BLOCKED",
 566    "RUN_TRAFFIC_BLOCKED",
 567    "RUN_TRANSCRIPT_BLOCKED",
 568    "RUN_WALL_BLOCKED",
 569    "RUN_WRITEUP_BLOCKED",
 570    "TRANSCRIPT_REFUSALS",
 571    "WALL_REFUSALS",
 572    "AccountDeletionAccepted",
 573    "AccountKeyRow",
 574    "AchievementCatalogEntry",
 575    "AchievementCopy",
 576    "AchievementProgress",
 577    "AchievementSummary",
 578    "Activity",
 579    "ActivityTimeseries",
 580    "ActivityTimeseriesBucket",
 581    "AddEmailRequest",
 582    "AdminArtifactStorageFlag",
 583    "AdminAuthoredChallengeDetail",
 584    "AdminAuthoredChallengeRow",
 585    "AdminAuthoredRevisionRow",
 586    "AdminChallengeLastError",
 587    "AdminChallengeLatencyBucket",
 588    "AdminChallengeQuestions",
 589    "AdminChallengeStatsRow",
 590    "AdminChallengeTimeseries",
 591    "AdminChallengeTimeseriesBucket",
 592    "AdminChangelogEntryInfo",
 593    "AdminCompetitionAttachmentsResponse",
 594    "AdminFeedbackRow",
 595    "AdminHarnessRow",
 596    "AdminHealthFlags",
 597    "AdminOverview",
 598    "AdminOverviewCounts",
 599    "AdminPatchSubmissionDetail",
 600    "AdminPatchSubmissionInfo",
 601    "AdminPatchSubmissionPage",
 602    "AdminRecentError",
 603    "AdminSilentChallenge",
 604    "AdminSolveCell",
 605    "AdminSolveMatrix",
 606    "AdminSolveMatrixChallenge",
 607    "AdminSolveMatrixTeam",
 608    "AdminStuckInstance",
 609    "AdminTaskInfo",
 610    "AdminTaskListResponse",
 611    "AdminTaskLogLine",
 612    "AdminTaskLogsResponse",
 613    "AdminTaskSubmitRequest",
 614    "AdminTeamRow",
 615    "AdminTimeseries",
 616    "AdminTimeseriesBucket",
 617    "AdminTimeseriesMetric",
 618    "AdminTokenRevokeResponse",
 619    "AdminTrafficInstanceRow",
 620    "AdminTrafficSummary",
 621    "AdminTrafficTeamRow",
 622    "AdminUnhealthyNode",
 623    "AdminUserInfo",
 624    "AffiliationInfo",
 625    "AffiliationSelection",
 626    "AgentActionRow",
 627    "AgentActionsPage",
 628    "AgentEntrantInfo",
 629    "AgentPulseBeat",
 630    "AgentPulseLane",
 631    "AgentPulsePage",
 632    "AnnouncementCreate",
 633    "AnnouncementInfo",
 634    "AnnouncementUpdate",
 635    "AnswerTraceHit",
 636    "AnswerTraceRequest",
 637    "AnswerTraceResponse",
 638    "ArchiveStoreReport",
 639    "AttachmentList",
 640    "AuditFinding",
 641    "AuthTokenResponse",
 642    "AuthorReputation",
 643    "AuthoredAuditReport",
 644    "AuthoredAutoSmoke",
 645    "AuthoredBuildCapability",
 646    "AuthoredChallengeFileBody",
 647    "AuthoredChallengeFileEntry",
 648    "AuthoredChallengeFileRequest",
 649    "AuthoredChallengeInfo",
 650    "AuthoredChallengeReviewRequest",
 651    "AuthoredChallengeRow",
 652    "AuthoredCurationElsewhere",
 653    "AuthoredCurationState",
 654    "AuthoredDeclarations",
 655    "AuthoredGraduationInfo",
 656    "AuthoredQualityFlag",
 657    "AuthoredReceptionRow",
 658    "AuthoredReceptionState",
 659    "AuthoredRevisionDiff",
 660    "AuthoredRevisionFileChange",
 661    "AuthoredRevisionInfo",
 662    "AuthoredRevisionRow",
 663    "AuthoringDivision",
 664    "AuthoringReceptionInfo",
 665    "AuthoringReviewerInfo",
 666    "AuthoringScaleInfo",
 667    "AuthoringTemplateFile",
 668    "AuthoringTemplateManifest",
 669    "AwdArenaGate",
 670    "AwdArenaGateLink",
 671    "AwdArenaLink",
 672    "AwdArenaPeerRow",
 673    "AwdArenaPeersResponse",
 674    "AwdAttackAnchorResponse",
 675    "AwdAttackAnchorRow",
 676    "AwdAttackMatrixResponse",
 677    "AwdAttackMatrixRow",
 678    "AwdBoardEntry",
 679    "AwdBoardResponse",
 680    "AwdCaptureCorroborationRow",
 681    "AwdClockBrief",
 682    "AwdCorroborationResponse",
 683    "AwdDefenceAnchorResponse",
 684    "AwdDefenceAnchorRow",
 685    "AwdDefenceTimelineCell",
 686    "AwdDefenceTimelineResponse",
 687    "AwdDefenceTimelineRow",
 688    "AwdEventRow",
 689    "AwdEventsResponse",
 690    "AwdFlagResult",
 691    "AwdFlagSubmitRequest",
 692    "AwdFlagSubmitResponse",
 693    "AwdForensicAnalysis",
 694    "AwdForensicsResponse",
 695    "AwdForensicsRow",
 696    "AwdMatchConfig",
 697    "AwdMatchInfo",
 698    "AwdMatchStatus",
 699    "AwdProvisionPlanInfo",
 700    "AwdRoundHistoryResponse",
 701    "AwdRoundPoint",
 702    "AwdRoundSeries",
 703    "AwdSlaHistoryResponse",
 704    "AwdSlaProbeRow",
 705    "AwdTarget",
 706    "AwdTargetsResponse",
 707    "AwdVpnPeer",
 708    "AwdVpnRequest",
 709    "AwdVpnStatus",
 710    "BigLimitOffsetPage",
 711    "BigLimitOffsetPageOf100",
 712    "BlueprintHostInfo",
 713    "BlueprintMilestoneInfo",
 714    "BoardCriterion",
 715    "BuildPushScopeRequest",
 716    "BulkInviteRequest",
 717    "BulkInviteResponse",
 718    "BulkInviteResultRow",
 719    "CalendarBucket",
 720    "ChallengeBlueprint",
 721    "ChallengeBuildKickoffNodeResult",
 722    "ChallengeBuildKickoffResponse",
 723    "ChallengeBuildNodeState",
 724    "ChallengeBuildStateResponse",
 725    "ChallengeBuildStateRow",
 726    "ChallengeFacetCount",
 727    "ChallengeFacets",
 728    "ChallengeFlagStats",
 729    "ChallengeInfo",
 730    "ChallengePullKickoffNodeResult",
 731    "ChallengePullKickoffResponse",
 732    "ChallengePullNodeState",
 733    "ChallengePullStateResponse",
 734    "ChallengePullStateRow",
 735    "ChallengeRescanNodeResult",
 736    "ChallengeRescanResult",
 737    "ChallengeSolveAttempt",
 738    "ChallengeSolveAttemptsResponse",
 739    "ChallengeStats",
 740    "ChallengeTeamSolveSummary",
 741    "ChallengeValuePoint",
 742    "ChangelogAnnounceRequest",
 743    "ChangelogAnnounceResponse",
 744    "ChangelogEntryCreate",
 745    "ChangelogEntryInfo",
 746    "ChangelogEntryUpdate",
 747    "ChangelogImportEntry",
 748    "ChangelogImportRequest",
 749    "ChangelogImportResponse",
 750    "ChangelogLatestResponse",
 751    "ChangelogListResponse",
 752    "ChangelogSeenResponse",
 753    "CheckAnswerRequest",
 754    "CheckAnswerResponse",
 755    "ClusterInfo",
 756    "CompetitionAdminInfo",
 757    "CompetitionApplicationInfo",
 758    "CompetitionApplicationRequest",
 759    "CompetitionApplicationReviewRequest",
 760    "CompetitionApplicationRow",
 761    "CompetitionAttachmentInfo",
 762    "CompetitionAttachmentUpdate",
 763    "CompetitionAttachmentsResponse",
 764    "CompetitionChallengeBreakdown",
 765    "CompetitionChallengeRow",
 766    "CompetitionCreate",
 767    "CompetitionDetail",
 768    "CompetitionFormatPreset",
 769    "CompetitionInfo",
 770    "CompetitionInviteInfo",
 771    "CompetitionMembershipInfo",
 772    "CompetitionRecords",
 773    "CompetitionRegistrationInfo",
 774    "CompetitionRoster",
 775    "CompetitionRosterTeam",
 776    "CompetitionScoreDistribution",
 777    "CompetitionScoreHistory",
 778    "CompetitionSeriesConfig",
 779    "CompetitionSeriesInfo",
 780    "CompetitionSessionInfo",
 781    "CompetitionUpdate",
 782    "ContributorRow",
 783    "CreateFineGrainedTokenRequest",
 784    "CreateFineGrainedTokenResponse",
 785    "CreateInviteRequest",
 786    "CreateInviteResponse",
 787    "CreditedChallengeRow",
 788    "DeceptionStats",
 789    "DefenceInfo",
 790    "DefenceLiveFile",
 791    "DefenceMatrixResponse",
 792    "DefenceMatrixRow",
 793    "DefenceSource",
 794    "DeleteMeRequest",
 795    "DeploymentConfig",
 796    "DeviceApproveRequest",
 797    "DeviceCodeResponse",
 798    "DeviceInfoResponse",
 799    "DeviceTokenRequest",
 800    "DeviceTokenResponse",
 801    "DifficultyStat",
 802    "EasterEggClaim",
 803    "EmailCodeAuthInfo",
 804    "EmailSuppressionCreate",
 805    "EmailSuppressionInfo",
 806    "EmailSuppressionListResponse",
 807    "EmailTemplateInfo",
 808    "EmailTemplateListResponse",
 809    "EmailVerificationSent",
 810    "EmailVerificationStatus",
 811    "ErrorResponse",
 812    "EvalAllowanceGrant",
 813    "EvalAllowanceRow",
 814    "EvalArtifactBucket",
 815    "EvalArtifactReport",
 816    "EvalEntryRequest",
 817    "EvalLeaderboard",
 818    "EvalRunCoverage",
 819    "EvalTrendPoint",
 820    "FeedbackStats",
 821    "GauntletBuildCost",
 822    "GauntletBuildStep",
 823    "GauntletLibraryResponse",
 824    "GauntletModuleInfo",
 825    "GauntletPlanRequest",
 826    "GauntletPlanResponse",
 827    "GauntletPreset",
 828    "GauntletPreviewRequest",
 829    "GauntletPreviewResponse",
 830    "GauntletRangeCreate",
 831    "GauntletRangeCreated",
 832    "GauntletRangeInfo",
 833    "GauntletRangeListResponse",
 834    "GauntletShortfall",
 835    "GrantAuthoringReviewerRequest",
 836    "GrantCompetitionAdminRequest",
 837    "HarnessModelCell",
 838    "HarnessStanding",
 839    "HarnessTags",
 840    "HarnessUpdate",
 841    "HealthResponse",
 842    "ImageConsistencyRow",
 843    "InboxAnnouncement",
 844    "InboxCaptainRequest",
 845    "InboxCompetitionInvite",
 846    "InboxIncomingInvite",
 847    "InboxOutgoingRequest",
 848    "InboxResponse",
 849    "InstanceAnswersResponse",
 850    "InstanceInfo",
 851    "InstanceQuestionInfo",
 852    "InstanceRecordArtifacts",
 853    "InstanceRecordDetail",
 854    "InstanceRecordInfo",
 855    "InstanceRecordInfoDetail",
 856    "InstanceStatusResponse",
 857    "InstanceStorageReport",
 858    "InstanceTrafficPage",
 859    "InstanceTrafficWitnesses",
 860    "InstitutionCreate",
 861    "InstitutionInfo",
 862    "InstitutionPage",
 863    "InstitutionSuggestion",
 864    "InstitutionSuggestionPage",
 865    "LeaderboardCell",
 866    "LimitOffsetPageUpTo200",
 867    "LimitOffsetPageUpTo500",
 868    "LinkStartResponse",
 869    "LinkedIdentity",
 870    "LlmBudgetPage",
 871    "LlmBudgetResetInfo",
 872    "LlmBudgetResetRequest",
 873    "LlmBudgetRow",
 874    "LlmCallBody",
 875    "LlmCallPage",
 876    "LlmCallRow",
 877    "LlmModelOption",
 878    "LlmProviderChoice",
 879    "LlmProviderCreate",
 880    "LlmProviderInfo",
 881    "LlmProviderPage",
 882    "LlmProviderProbe",
 883    "LoginRequest",
 884    "MeResponse",
 885    "MetaAgents",
 886    "MetaAuthoredArchive",
 887    "MetaChallenges",
 888    "MetaContribute",
 889    "MetaEvents",
 890    "MetaInstanceTtl",
 891    "MetaLlm",
 892    "MetaMine",
 893    "MetaOps",
 894    "MetaPlatform",
 895    "MetaResponse",
 896    "MilestoneProgress",
 897    "ModelStanding",
 898    "MyAchievementsResponse",
 899    "MyEvalRunPage",
 900    "MyEvalRunRow",
 901    "MyLlmKey",
 902    "MyLlmKeyCreate",
 903    "MyLlmKeyProbe",
 904    "MyReactionsResponse",
 905    "MyRegistrationResponse",
 906    "MyReportRow",
 907    "MyReportsResponse",
 908    "MySolveSummary",
 909    "NodeAuthoredPush",
 910    "NodeAuthoredResult",
 911    "NodeAuthoredSmokePush",
 912    "NodeAuthoredSmokeResult",
 913    "NodeAuthoredSyncLine",
 914    "NodeCaptureStoreReport",
 915    "NodeCaptureStoreRow",
 916    "NodeHeartbeat",
 917    "NodeHeartbeatResponse",
 918    "NodeInfo",
 919    "NodeInviteInfo",
 920    "NodePatch",
 921    "NodeRangePush",
 922    "NodeRangeResult",
 923    "NodeRangeSyncLine",
 924    "NodeRangeVerifyPush",
 925    "NodeRangeVerifyResult",
 926    "NodeRegister",
 927    "NodeRegisterResponse",
 928    "NodeRegistryTicket",
 929    "NodeRegistryTicketRequest",
 930    "NotificationCategoryInfo",
 931    "NotificationChannelCell",
 932    "NotificationPreferenceUpdate",
 933    "NotificationPreferencesResponse",
 934    "OAuthProviderInfo",
 935    "OnboardingIntentRequest",
 936    "PasswordAuthInfo",
 937    "PatchStepInfo",
 938    "PatchSubmissionCreate",
 939    "PatchSubmissionInfo",
 940    "PlatformSettingInfo",
 941    "PlatformSettingPatch",
 942    "PlayerShellCreate",
 943    "PlayerShellTicket",
 944    "ProfilePatchRequest",
 945    "ProfileStats",
 946    "ProvidersResponse",
 947    "PublicTranscriptBlock",
 948    "PublicTranscriptResponse",
 949    "PublicTranscriptTurn",
 950    "QaChallengeView",
 951    "QaSubmissionCreate",
 952    "QuestionAttemptResetInfo",
 953    "QuestionAttemptResetRequest",
 954    "QuestionCoverageRow",
 955    "QuestionPublicInfo",
 956    "RangeProgress",
 957    "RangeProgressRow",
 958    "RangeReplay",
 959    "RangeReplayFrame",
 960    "RangeReplayPosition",
 961    "ReactionCount",
 962    "ReauthenticateRequest",
 963    "ReauthenticationOpened",
 964    "RecentUnlock",
 965    "RecordAuthoredChallenge",
 966    "RecordEvalRun",
 967    "RecordSolve",
 968    "RecordSubmission",
 969    "RecordTeam",
 970    "RegisterRequest",
 971    "RegistrationFieldSpec",
 972    "RegistrationFormSpec",
 973    "RegistrationRosterRow",
 974    "RegistrationSubmitRequest",
 975    "RegistrationSummary",
 976    "RegistrationTeamRow",
 977    "RegistryGcStatus",
 978    "RegistryManifestRow",
 979    "RegistryPushTarget",
 980    "RegistryQuotaInfo",
 981    "RegistryReclaimItem",
 982    "RegistryReclaimPlan",
 983    "RegistryReclaimRequest",
 984    "RegistryRepo",
 985    "RegistryRepoList",
 986    "RegistryTagList",
 987    "RegistryUsageReport",
 988    "RegistryUsageRow",
 989    "RenewResponse",
 990    "ReportAttachmentInfo",
 991    "ReportAttachmentsResponse",
 992    "ReportClaimRow",
 993    "ReportClaimsPage",
 994    "ReportGradeInfo",
 995    "ReportGradeSummary",
 996    "ReportGradesResponse",
 997    "ReportInfo",
 998    "ReportPipeline",
 999    "ReportPipelineEvent",
1000    "ReportRosterResponse",
1001    "ReportRosterRow",
1002    "ReportSaveRequest",
1003    "ReportSaveResponse",
1004    "ReportStanding",
1005    "ReportVersionsResponse",
1006    "RevealedAnswer",
1007    "ReviewDecisionRequest",
1008    "RunTranscriptRequest",
1009    "RunWallRequest",
1010    "RunWriteup",
1011    "ScheduledJobInfo",
1012    "ScheduledJobPatch",
1013    "ScopeCatalogEntry",
1014    "ScoreBucket",
1015    "ScoreHistoryPoint",
1016    "ScoreHistorySeries",
1017    "ScoreboardEntry",
1018    "SeriesOccurrencePreview",
1019    "SeriesPreview",
1020    "SetPasswordRequest",
1021    "ShellRecordingInfo",
1022    "SshCredential",
1023    "SshExchangeRequest",
1024    "StarGazerVerifyResponse",
1025    "StartRequest",
1026    "StartResponse",
1027    "StopResponse",
1028    "SubmissionCreate",
1029    "SubmissionResponse",
1030    "TagStat",
1031    "TeamAchievement",
1032    "TeamAffiliationSelection",
1033    "TeamCaptainRequest",
1034    "TeamDetail",
1035    "TeamDisqualifyRequest",
1036    "TeamInfo",
1037    "TeamInviteInfo",
1038    "TeamLogoUploadResponse",
1039    "TeamMemberInfo",
1040    "TeamStandingInfo",
1041    "TestEmailRequest",
1042    "TestEmailResult",
1043    "TokenInfo",
1044    "TokenLifetimeRequest",
1045    "TokenScopeUpdate",
1046    "TokenScopesResponse",
1047    "TokenTemplatesResponse",
1048    "TokenTraceLink",
1049    "TokenTraceRequest",
1050    "TokenTraceResponse",
1051    "TrafficWitness",
1052    "TrendPoint",
1053    "UpdateUserRoleRequest",
1054    "UpdateUserStandingsRequest",
1055    "UserEmailInfo",
1056    "UserInfo",
1057    "UserScoreboardEntry",
1058    "UserSolveTrend",
1059    "VendorStandardSet",
1060    "VendorStanding",
1061    "VerifyAddedEmailRequest",
1062    "VerifyAnswerRequest",
1063    "VerifyAnswerResponse",
1064    "VerifyEmailRequest",
1065    "VirtualGhostBoard",
1066    "VirtualGhostRow",
1067]
MAX_FLAGS_PER_SUBMISSION = 1000
PROFILE_VISIBILITY_KEYS = frozenset({'email', 'last_active_at', 'attempts_count', 'country', 'activity_calendar', 'rank_history', 'website_url', 'bio', 'achievements', 'solves_count', 'timezone', 'social_links', 'tag_stats', 'solve_trend', 'difficulty_stats'})
RUN_ARTIFACT_BLOCKED = frozenset({'none', 'not_configured', 'reclaimed', 'not_yours', 'failed', 'empty'})
RUN_TRAFFIC_BLOCKED = frozenset({'reclaimed', 'not_yours', 'no_instance'})
RUN_TRANSCRIPT_BLOCKED = frozenset({'anonymous', 'not_yours', 'not_an_agent', 'no_entrant'})
RUN_WALL_BLOCKED = frozenset({'not_yours', 'not_an_agent', 'no_entrant'})
RUN_WRITEUP_BLOCKED = frozenset({'none', 'not_yours'})
TRANSCRIPT_REFUSALS = {'not_yours': (403, 'run_transcript_not_yours', "Only the agent's author can publish its conversation"), 'not_an_agent': (409, 'run_transcript_not_an_agent', "This run's entrant is not an agent, so there is no conversation to publish"), 'no_entrant': (409, 'run_transcript_no_entrant', 'This run has no entrant whose conversation could be published'), 'anonymous': (409, 'run_transcript_anonymous', 'An anonymous entrant cannot publish a full conversation — it would identify them')}
WALL_REFUSALS = {'not_yours': (403, 'run_wall_not_yours', "Only the agent's author can put it on the wall"), 'not_an_agent': (409, 'run_wall_not_an_agent', "This run's entrant is not an agent, so the wall has nothing to draw"), 'no_entrant': (409, 'run_wall_no_entrant', 'This run has no entrant to put on the wall')}
class AccountDeletionAccepted(ctfy.core.models.CtfyModel):
411class AccountDeletionAccepted(CtfyModel):
412    """What ``DELETE /me`` did — carried out, or scheduled.
413
414    ⚠️ **A bare 204 could not say which**, and they are opposite facts
415    with opposite next steps: one reader has nothing left to do, the
416    other has a window in which signing in cancels the whole thing. That
417    is the shape ``make_probe`` and ``AttackOutcome`` both record — a
418    response the caller cannot act on differently is a response that
419    answered a different question.
420
421    ⚠️ **``deleted`` defaults ``False`` on both sides, and the asymmetry
422    is the argument.** A producer that forgets tells the caller their
423    account is *scheduled* when it is gone: they try to sign in, fail,
424    and learn the truth. The other default tells them it is *gone* when
425    it is recoverable, so they never try — and that mistake erases the
426    account by inaction, which is the one this whole feature exists to
427    prevent.
428    """
429
430    #: True when the cascade already ran inside the request — which is
431    #: what a grace of ``0`` means, and what this platform did before
432    #: the window existed.
433    deleted: bool = False
434    #: When the deletion becomes final. Equal to the request instant on
435    #: the ``deleted`` branch, so a reader never has to treat the two
436    #: cases as different shapes. Required: a default here would be a
437    #: new claim about an instant.
438    effective_at: AwareDatetime

What DELETE /me did — carried out, or scheduled.

⚠️ A bare 204 could not say which, and they are opposite facts with opposite next steps: one reader has nothing left to do, the other has a window in which signing in cancels the whole thing. That is the shape make_probe and AttackOutcome both record — a response the caller cannot act on differently is a response that answered a different question.

⚠️ deleted defaults False on both sides, and the asymmetry is the argument. A producer that forgets tells the caller their account is scheduled when it is gone: they try to sign in, fail, and learn the truth. The other default tells them it is gone when it is recoverable, so they never try — and that mistake erases the account by inaction, which is the one this whole feature exists to prevent.

deleted: bool = False
effective_at: pydantic.types.AwareDatetime = PydanticUndefined
class AccountKeyRow(ctfy.core.models.CtfyModel):
656class AccountKeyRow(CtfyModel):
657    """One account's own credential, on the operator's merged page.
658
659    ⚠️ **No secret and no fingerprint**, unlike the fleet row above: four
660    characters of somebody else's credential buys the operator nothing —
661    they are reading *who pays*, not telling two of one person's keys
662    apart — and withholding them is what lets the whole listing be a
663    column projection that decrypts nothing. See
664    :class:`~ctfy.core.state.models.OwnedLlmKeySummary`.
665
666    ⚠️ **No Test button either, which is why the stored verdict is
667    here.** A probe spends somebody else's upstream account, so the
668    operator reads what the *owner's* last press established — with its
669    instant, because a code with no date reads as current for ever.
670
671    ⚠️ Served in a plain ``BigLimitOffsetPage``, deliberately not a
672    bespoke envelope like :class:`LlmProviderPage`: that one carries two
673    facts about the *deployment* (is there a gateway, can a secret be
674    sealed) which have nowhere else to ride. This carries none, and a
675    hand-written ``{items, total}`` would be a fourth page shape the
676    client's ``usePagination`` cannot read — it wants ``offset`` and
677    ``limit`` back as well, which is what tells a pager it is on the
678    last page rather than at the end of the list.
679    """
680
681    owner_user_id: str = ""
682    #: ``display_name or email``, composed server-side. ⚠️ ``""`` means
683    #: *no name to show* — an account that has gone — which is a
684    #: different fact from a blank display name, and the id beside it is
685    #: what keeps the row addressable either way.
686    owner_display_name: str = ""
687    flavor: FlavorOrUnset = ""
688    base_url: str = ""
689    label: str = ""
690    added_at: str = ""
691    #: ⚠️ ``""`` is *never spent by a run*, not "a while ago": for an
692    #: operator asking why an entrant's agent produced nothing, those are
693    #: opposite answers.
694    last_used_at: str = ""
695    last_probe_code: str = ""
696    last_probe_at: str = ""

One account's own credential, on the operator's merged page.

⚠️ No secret and no fingerprint, unlike the fleet row above: four characters of somebody else's credential buys the operator nothing — they are reading who pays, not telling two of one person's keys apart — and withholding them is what lets the whole listing be a column projection that decrypts nothing. See ~ctfy.core.state.models.OwnedLlmKeySummary.

⚠️ No Test button either, which is why the stored verdict is here. A probe spends somebody else's upstream account, so the operator reads what the owner's last press established — with its instant, because a code with no date reads as current for ever.

⚠️ Served in a plain BigLimitOffsetPage, deliberately not a bespoke envelope like LlmProviderPage: that one carries two facts about the deployment (is there a gateway, can a secret be sealed) which have nowhere else to ride. This carries none, and a hand-written {items, total} would be a fourth page shape the client's usePagination cannot read — it wants offset and limit back as well, which is what tells a pager it is on the last page rather than at the end of the list.

owner_user_id: str = ''
owner_display_name: str = ''
flavor: Union[ctfy.core.enums.Flavor, Literal['']] = ''
base_url: str = ''
label: str = ''
added_at: str = ''
last_used_at: str = ''
last_probe_code: str = ''
last_probe_at: str = ''
class AchievementCatalogEntry(ctfy.core.models.CtfyModel):
53class AchievementCatalogEntry(CtfyModel):
54    """One badge as advertised to the frontend."""
55
56    id: str
57    name: str
58    description: str
59    icon: str
60    tier: str
61    secret: bool = False
62    #: A secret badge's words in each non-English locale (``AchievementCopy``),
63    #: keyed by the tags in ``ctfy.core.localized.LOCALES``; ``{}`` for a
64    #: public badge, whose words are in the frontend bundle. Present only on a
65    #: row the caller is entitled to — this route already withholds an
66    #: unearned secret badge, and the copy travels exactly where the row does.
67    localized: dict[str, AchievementCopy] = Field(default_factory=dict)
68    # Only populated for locked entries on /me/achievements — the
69    # general catalog endpoint and unlocked entries don't carry it.
70    progress: AchievementProgress | None = None
71    # Score weight per tier (bronze=10, silver=25, gold=50, secret=100).
72    points: int = 0
73    # Number of distinct teams that have unlocked this badge.
74    earned_by_count: int = 0
75    # ``common`` | ``uncommon`` | ``rare`` | ``epic`` | ``mythic`` |
76    # ``unearned``.
77    rarity: str = "unearned"

One badge as advertised to the frontend.

id: str = PydanticUndefined
name: str = PydanticUndefined
description: str = PydanticUndefined
icon: str = PydanticUndefined
tier: str = PydanticUndefined
secret: bool = False
localized: dict[str, AchievementCopy] = PydanticUndefined
progress: AchievementProgress | None = None
points: int = 0
earned_by_count: int = 0
rarity: str = 'unearned'
class AchievementCopy(ctfy.core.models.CtfyModel):
44class AchievementCopy(CtfyModel):
45    """A badge's name and description in one non-English language."""
46
47    model_config = ConfigDict(frozen=True)
48
49    name: str
50    description: str

A badge's name and description in one non-English language.

name: str = PydanticUndefined
description: str = PydanticUndefined
class AchievementProgress(ctfy.core.models.CtfyModel):
14class AchievementProgress(CtfyModel):
15    """Quantifiable progress toward a locked badge.
16
17    ``current`` is what the team has so far, ``target`` is the
18    threshold the rule predicate checks against. Provided only when
19    the achievement has a registered progress provider (decathlete,
20    centurion, completionist, sisyphus, unicorn). Time-of-day /
21    first-blood / easter-egg badges return ``progress = null``.
22    """
23
24    current: int
25    target: int

Quantifiable progress toward a locked badge.

current is what the team has so far, target is the threshold the rule predicate checks against. Provided only when the achievement has a registered progress provider (decathlete, centurion, completionist, sisyphus, unicorn). Time-of-day / first-blood / easter-egg badges return progress = null.

current: int = PydanticUndefined
target: int = PydanticUndefined
class AchievementSummary(ctfy.core.models.CtfyModel):
 98class AchievementSummary(CtfyModel):
 99    """Aggregate roll-up for the Achievements page header."""
100
101    unlocked_count: int = 0
102    total_count: int = 0
103    score: int = 0
104    total_score: int = 0

Aggregate roll-up for the Achievements page header.

unlocked_count: int = 0
total_count: int = 0
score: int = 0
total_score: int = 0
class Activity(ctfy.core.models.CtfyModel):
18class Activity(CtfyModel):
19    """A single activity event in the platform log."""
20
21    id: str = ""
22    timestamp: datetime | None = None
23    event: str  # See PlatformEvent enum for valid values
24    team_id: str = ""
25    team_name: str = ""
26    challenge_id: str = ""
27    # Who triggered this event — surfaced on the public feed so admins
28    # can filter by actor and auditors can trace back. Values mirror
29    # the ActivityState fields in core.state.models.
30    actor_id: str = ""
31    actor_name: str = ""
32    actor_type: str = ""
33    # Denormalised actor avatar (GitHub/Google), surfaced on the feed so
34    # each event can render the actor's real face. Empty ⇒ the frontend
35    # renders a generated initials chip instead.
36    actor_avatar_url: str = ""
37    detail: ActivityDetail = Field(default_factory=ActivityDetail)

A single activity event in the platform log.

id: str = ''
timestamp: datetime.datetime | None = None
event: str = PydanticUndefined
team_id: str = ''
team_name: str = ''
challenge_id: str = ''
actor_id: str = ''
actor_name: str = ''
actor_type: str = ''
actor_avatar_url: str = ''
detail: ctfy.core.activity.ActivityDetail = PydanticUndefined
class ActivityTimeseries(ctfy.core.models.CtfyModel):
55class ActivityTimeseries(CtfyModel):
56    """Bucketed activity counts for the per-team activity histogram.
57
58    Same ``ts`` semantics as ``AdminTimeseries`` — oldest-first, right
59    edge anchored to "now". ``events`` is the sorted union of event
60    types that appear in any bucket, so the frontend has a stable list
61    of stack segments to render even when individual buckets are empty.
62    """
63
64    window_s: int = 0
65    bucket_s: int = 0
66    events: list[str] = Field(default_factory=list)
67    buckets: list[ActivityTimeseriesBucket] = Field(default_factory=list)

Bucketed activity counts for the per-team activity histogram.

Same ts semantics as AdminTimeseries — oldest-first, right edge anchored to "now". events is the sorted union of event types that appear in any bucket, so the frontend has a stable list of stack segments to render even when individual buckets are empty.

window_s: int = 0
bucket_s: int = 0
events: list[str] = PydanticUndefined
buckets: list[ActivityTimeseriesBucket] = PydanticUndefined
class ActivityTimeseriesBucket(ctfy.core.models.CtfyModel):
40class ActivityTimeseriesBucket(CtfyModel):
41    """One bucket of the team's activity histogram.
42
43    ``counts`` is keyed by event type (``flag_correct``, ``instance_started``,
44    …) so the frontend can stack each event as its own bar segment without
45    a follow-up shape-change to add new event types.
46
47    See :class:`AdminTimeseriesBucket` for the ``partial`` semantics.
48    """
49
50    ts: float = 0.0  # right edge, Unix seconds
51    counts: dict[str, int] = Field(default_factory=dict)
52    partial: bool = False

One bucket of the team's activity histogram.

counts is keyed by event type (flag_correct, instance_started, …) so the frontend can stack each event as its own bar segment without a follow-up shape-change to add new event types.

See AdminTimeseriesBucket for the partial semantics.

ts: float = 0.0
counts: dict[str, int] = PydanticUndefined
partial: bool = False
class AddEmailRequest(ctfy.core.models.CtfyModel):
210class AddEmailRequest(CtfyModel):
211    """Body for ``POST /me/emails`` — start proving a *new* address.
212
213    ⚠️ **This one does carry an address, and that is the whole
214    difference from ``VerifyEmailRequest``.** Its sibling refuses an
215    address field because the code there goes to the account's stored
216    mailbox and a parameter would let a caller mail anywhere and attach
217    anyone's address as proved. Here the code goes *to the address in
218    this body*, so it proves nothing unless the caller can read that
219    mailbox — the parameter is the act, not a bypass of it.
220    """
221
222    email: EmailStr

Body for POST /me/emails — start proving a new address.

⚠️ This one does carry an address, and that is the whole difference from VerifyEmailRequest. Its sibling refuses an address field because the code there goes to the account's stored mailbox and a parameter would let a caller mail anywhere and attach anyone's address as proved. Here the code goes to the address in this body, so it proves nothing unless the caller can read that mailbox — the parameter is the act, not a bypass of it.

email: pydantic.networks.EmailStr = PydanticUndefined
class AdminArtifactStorageFlag(ctfy.core.models.CtfyModel):
212class AdminArtifactStorageFlag(CtfyModel):
213    """The archive store is big enough that somebody should look.
214
215    ⚠️ **``verdict`` carries *why*, not just *that*.** "50 GB with a
216    30-day window" is a steady state somebody chose; "50 GB and nothing
217    reclaims it" is a ramp. The advice differs — shorten the window, or
218    set one at all — and a bare number cannot tell them apart.
219    """
220
221    #: ``growing`` = over the threshold with no retention window;
222    #: ``over`` = a window exists and is not keeping up.
223    verdict: str = ""
224    stored_runs: int = 0
225    stored_bytes: int = 0
226    alert_bytes: int = 0
227    retention_s: int = 0

The archive store is big enough that somebody should look.

⚠️ verdict carries why, not just that. "50 GB with a 30-day window" is a steady state somebody chose; "50 GB and nothing reclaims it" is a ramp. The advice differs — shorten the window, or set one at all — and a bare number cannot tell them apart.

verdict: str = ''
stored_runs: int = 0
stored_bytes: int = 0
alert_bytes: int = 0
retention_s: int = 0
class AdminAuthoredChallengeDetail(ctfy.core.models.CtfyModel):
704class AdminAuthoredChallengeDetail(CtfyModel):
705    """One upload, opened.
706
707    The queue row plus what is actually in the tree. Reading one row is
708    what makes carrying the file list affordable here and not on the
709    listing.
710    """
711
712    row: AdminAuthoredChallengeRow
713    files: list[AuthoredChallengeFileEntry] = Field(default_factory=list)
714    total_bytes: int = 0
715    #: Whether publishing asks a platform admin to confirm they read the
716    #: files marked ``executes`` (plan P2.3) — true exactly when any file
717    #: is marked. ⚠️ Defaults ``False``, and the publish route asks the
718    #: question itself, so an older client that sends no confirmation is
719    #: refused with the reason rather than let through.
720    needs_exec_review: bool = False

One upload, opened.

The queue row plus what is actually in the tree. Reading one row is what makes carrying the file list affordable here and not on the listing.

row: AdminAuthoredChallengeRow = PydanticUndefined
files: list[AuthoredChallengeFileEntry] = PydanticUndefined
total_bytes: int = 0
needs_exec_review: bool = False
class AdminAuthoredChallengeRow(ctfy.server.models.AuthoredDeclarations):
554class AdminAuthoredChallengeRow(AuthoredDeclarations):
555    """One upload as the operator's review queue lists it.
556
557    ⚠️ **Composed server-side, and the reason is ``AdminHarnessRow``'s.**
558    That queue "could not answer any of the three questions an approval
559    turns on": the author was a uuid, the reviewer was a uuid, and the
560    gate was recomputed. This one names both people, because deciding
561    whether to run somebody's code starts with knowing whose it is —
562    ``team_id`` is a uuid and a table of uuids is a table nobody triages.
563
564    ⚠️ **The tree is not here, and neither is its size.** The queue is
565    over a whole event; the bytes and the file list belong to
566    :class:`AdminAuthoredChallengeDetail`, which reads one row. See
567    ``AuthoredChallengeSummary`` for why a *cheap* size column is worse
568    than none.
569
570    ⚠️ ``/admin`` is outside `test_anonymity_doors`' scope on purpose —
571    the organiser is precisely who sees through an anonymous entry — so
572    ``team_name`` travels here where it may not on a board.
573    """
574
575    id: str
576    #: What the author called it. ⚠️ Unique per ``(competition_id,
577    #: team_id)`` and no further — see `AuthoredChallengeRow.slug`.
578    slug: str = ""
579    #: ``metadata.yaml``'s own ``name:``, read at ingest. ``""`` means
580    #: the metadata did not say; a reader falls back to `slug`, never
581    #: to the id.
582    title: str = ""
583    competition_id: str
584    #: ``""`` when the competition has been deleted out from under the
585    #: row: ``authored_challenges`` is in ``_KEEP_ON_DELETE``, so a
586    #: reviewer can still see what was submitted and to what.
587    competition_title: str = ""
588    team_id: str
589    #: ⚠️ Blank means *this team is gone*, never *unnamed*. Rendered as
590    #: the id rather than as an empty cell, or a deleted team's uploads
591    #: read as somebody else's.
592    team_name: str = ""
593    user_id: str
594    author_display_name: str = ""
595    submitted_at: AwareDatetime | None = None
596    review_status: ReviewStatus = "pending"
597    #: The reviewing operator's id, ``""`` while nobody has ruled.
598    reviewed_by: str = ""
599    reviewed_by_display_name: str = ""
600    reviewed_at: AwareDatetime | None = None
601    review_note: str = ""
602    #: Whether the tree is in this deployment's catalog, **read off the
603    #: disk** rather than from a column.
604    #:
605    #: ⚠️ There is deliberately no ``published_at``, exactly as
606    #: `GeneratedRangeState` has no status column: the directory is the
607    #: fact, and a column beside it is a second claim free to disagree
608    #: the moment anything removes the tree. ``review_status`` says what
609    #: an operator decided; this says what is there.
610    #:
611    #: ⚠️ Defaults **False**, the loud direction: an older server or a
612    #: producer that forgets reports *not published*, which costs a press
613    #: that then refuses on the id. The other default would tell an
614    #: operator their approved batch is live when nothing is on disk.
615    in_catalog: bool = False
616    #: Where it stands with **this event's** challenge set — the last
617    #: step of the chain, and the one nothing told the operator about.
618    #:
619    #: ⚠️ **Not ``in_catalog`` a second time.** That one is a fact about
620    #: this deployment's disk; an approved, published, node-pushed,
621    #: smoke-passing challenge that no organiser curated is live in the
622    #: catalog and in front of nobody, and pays its author nothing.
623    #: Measured before this field: ``not_curated`` reached the author on
624    #: four surfaces and **zero** organiser surfaces — the review queue
625    #: did not name ``challenge_ids`` at all.
626    #:
627    #: ⚠️ Defaults ``not_curated``, the direction that reads as
628    #: unfinished: a producer that did not ask must not report an upload
629    #: as being in front of entrants.
630    curation: AuthoredCurationState = "not_curated"
631    #: The competitions **other than this upload's own** whose curated
632    #: set lists it — where it went after the event it was written for
633    #: (plan P1.5). ``curation`` above stays the own-event answer.
634    #:
635    #: ⚠️ From the **same producer** for both audiences
636    #: (`authoring_curation.curated_elsewhere`), `curation`'s rule — but
637    #: the author's copy names only competitions the author may see, so
638    #: an upload is not how a hidden event's name reaches somebody it is
639    #: hidden from. The operator's copy names all of them.
640    #:
641    #: ⚠️ Empty by default, and empty is a real answer: nothing curated
642    #: it anywhere else. A bucket competition never appears, because
643    #: nothing is curated into a set resolved from a catalog directory.
644    curated_elsewhere: list[AuthoredCurationElsewhere] = Field(default_factory=list)
645    #: What the last smoke run on a worker found, ``""`` if none has run.
646    #:
647    #: ⚠️ **On the row rather than only in the task record**, and the
648    #: reason is what an approval is for. An operator presses smoke and
649    #: the verdict came back as a `TaskResult` — so reading it later
650    #: meant finding that task, and re-reviewing a challenge meant
651    #: taking somebody's word for whether it had ever been run. It is
652    #: the same column the author reads; the two audiences act on it
653    #: differently (fix it, versus decide about it) and neither can act
654    #: without seeing it.
655    #:
656    #: ⚠️ **Usually ``""`` in this queue, and that is correct rather
657    #: than useless.** Smoking needs an approved tree pushed to a node,
658    #: so a row awaiting a first verdict has never been smoked — the
659    #: blank is the honest state, and the column earns itself on the
660    #: second look at a challenge rather than the first.
661    smoke_verdict: AuthoredSmokeState = ""
662    smoke_at: AwareDatetime | None = None
663    #: A smoke run queued or running for this tree — see
664    #: `AuthoredChallengeRow.smoke_in_flight`. What stops a reviewer
665    #: pressing smoke for a run that is already on its way, and what the
666    #: queue's ``in_flight`` filter selects.
667    smoke_in_flight: AuthoredSmokeInFlightState = ""
668    #: The platform admin who read this upload's execution surface at
669    #: publish (plan P2.3), ``""`` if nobody has — including every upload
670    #: published before the stamp existed, which the row does not claim.
671    exec_reviewed_by: str = ""
672    #: What to call ``exec_reviewed_by``; ``""`` beside a blank id.
673    exec_reviewed_by_display_name: str = ""
674    exec_reviewed_at: AwareDatetime | None = None
675    #: Players' reports that reached the threshold (plan P2.6), in reading
676    #: order. ⚠️ Empty is two facts — nobody reported enough, or the
677    #: threshold is off — and both mean *nothing to act on here*, the only
678    #: thing a queue reader asks of it.
679    quality_flags: list[AuthoredQualityFlag] = Field(default_factory=list)
680    #: The revision proposed for this upload and not yet applied, or
681    #: ``None`` (plan P3.1) — what the queue's ``revision`` filter selects.
682    revision: AdminAuthoredRevisionRow | None = None
683    #: How players received this uploader's published challenges (plan
684    #: P3.5), as this reader may attribute them. ``None`` is *nobody to
685    #: read* — the uploader's account is gone — and never the same thing as
686    #: a newcomer's reading, which says ``published: 0``.
687    author_reputation: AuthorReputation | None = None

One upload as the operator's review queue lists it.

⚠️ Composed server-side, and the reason is AdminHarnessRow's. That queue "could not answer any of the three questions an approval turns on": the author was a uuid, the reviewer was a uuid, and the gate was recomputed. This one names both people, because deciding whether to run somebody's code starts with knowing whose it is — team_id is a uuid and a table of uuids is a table nobody triages.

⚠️ The tree is not here, and neither is its size. The queue is over a whole event; the bytes and the file list belong to AdminAuthoredChallengeDetail, which reads one row. See AuthoredChallengeSummary for why a cheap size column is worse than none.

⚠️ /admin is outside test_anonymity_doors' scope on purpose — the organiser is precisely who sees through an anonymous entry — so team_name travels here where it may not on a board.

id: str = PydanticUndefined
slug: str = ''
title: str = ''
competition_id: str = PydanticUndefined
competition_title: str = ''
team_id: str = PydanticUndefined
team_name: str = ''
user_id: str = PydanticUndefined
author_display_name: str = ''
submitted_at: pydantic.types.AwareDatetime | None = None
review_status: Literal['pending', 'approved', 'rejected'] = 'pending'
reviewed_by: str = ''
reviewed_by_display_name: str = ''
reviewed_at: pydantic.types.AwareDatetime | None = None
review_note: str = ''
in_catalog: bool = False
curation: Literal['curated', 'not_curated', 'auto_bucket'] = 'not_curated'
curated_elsewhere: list[AuthoredCurationElsewhere] = PydanticUndefined
smoke_verdict: Union[Literal[''], Literal['solved', 'unsolved', 'estate_failed', 'malformed', 'errored']] = ''
smoke_at: pydantic.types.AwareDatetime | None = None
smoke_in_flight: Union[Literal[''], Literal['queued', 'running']] = ''
exec_reviewed_by: str = ''
exec_reviewed_by_display_name: str = ''
exec_reviewed_at: pydantic.types.AwareDatetime | None = None
quality_flags: list[AuthoredQualityFlag] = PydanticUndefined
revision: AdminAuthoredRevisionRow | None = None
author_reputation: AuthorReputation | None = None
class AdminAuthoredRevisionRow(ctfy.server.models.AuthoredRevisionRow):
166class AdminAuthoredRevisionRow(AuthoredRevisionRow):
167    """A revision as a reviewer reads it: who ruled and who applied, named."""
168
169    #: The live version it was proposed against — the upload's
170    #: ``submitted_at`` at that moment.
171    base_submitted_at: AwareDatetime | None = None
172    reviewed_by: str = ""
173    reviewed_by_display_name: str = ""
174    applied_by: str = ""
175    applied_by_display_name: str = ""

A revision as a reviewer reads it: who ruled and who applied, named.

base_submitted_at: pydantic.types.AwareDatetime | None = None
reviewed_by: str = ''
reviewed_by_display_name: str = ''
applied_by: str = ''
applied_by_display_name: str = ''
class AdminChallengeLastError(ctfy.core.models.CtfyModel):
111class AdminChallengeLastError(CtfyModel):
112    """Most-recent error against a challenge, surfaced on the per-challenge
113    admin row so an admin can spot a broken challenge without opening
114    instance history."""
115
116    ts: float = 0.0  # stopped_at of the failing instance
117    message: str = ""
118    instance_id: str = ""

Most-recent error against a challenge, surfaced on the per-challenge admin row so an admin can spot a broken challenge without opening instance history.

ts: float = 0.0
message: str = ''
instance_id: str = ''
class AdminChallengeLatencyBucket(ctfy.core.models.CtfyModel):
156class AdminChallengeLatencyBucket(CtfyModel):
157    """One fixed bucket of the cluster-wide launch-duration histogram.
158
159    The Challenges admin table paginates server-side, so the pooled
160    "how slow are launches overall" picture can't be re-derived in the
161    browser from one page of rows — this endpoint pools every
162    challenge's recent successful launches and bins them with the same
163    absolute edges the per-row drawer uses, so cross-page the shape
164    stays comparable.
165    """
166
167    label: str
168    count: int = 0

One fixed bucket of the cluster-wide launch-duration histogram.

The Challenges admin table paginates server-side, so the pooled "how slow are launches overall" picture can't be re-derived in the browser from one page of rows — this endpoint pools every challenge's recent successful launches and bins them with the same absolute edges the per-row drawer uses, so cross-page the shape stays comparable.

label: str = PydanticUndefined
count: int = 0
class AdminChallengeQuestions(ctfy.core.models.CtfyModel):
309class AdminChallengeQuestions(CtfyModel):
310    """Every question a challenge declares, gated ones included.
311
312    The organiser's counterpart to the catalog projection, which omits
313    gated entries (see ``projections.question_infos``). Without this an
314    organiser running an exercise has no way to answer "which milestones
315    exist, and why did this team not get credit for one" short of reading
316    ``metadata.yaml`` off disk — and the corpus is a submodule the
317    deployment mounts read-only, not something the admin console can see.
318
319    Deliberately the **same** ``QuestionPublicInfo`` rows rather than a
320    wider admin model: this route changes *who may read*, not *what is
321    said*. The groundtruth is still never projected — a static ``answer``
322    lives in metadata and a dynamic one is minted per instance, and
323    neither belongs on a page. ``AdminTeamRow`` is a separate model
324    because it adds a field the public one must not carry; there is no
325    such field here.
326    """
327
328    challenge_id: str
329    questions: list[QuestionPublicInfo] = Field(default_factory=list)
330    # How many of ``questions`` are gated behind ``requires``. Served
331    # rather than derived so a client cannot disagree with the server
332    # about what the catalog is withholding.
333    gated_count: int = 0

Every question a challenge declares, gated ones included.

The organiser's counterpart to the catalog projection, which omits gated entries (see projections.question_infos). Without this an organiser running an exercise has no way to answer "which milestones exist, and why did this team not get credit for one" short of reading metadata.yaml off disk — and the corpus is a submodule the deployment mounts read-only, not something the admin console can see.

Deliberately the same QuestionPublicInfo rows rather than a wider admin model: this route changes who may read, not what is said. The groundtruth is still never projected — a static answer lives in metadata and a dynamic one is minted per instance, and neither belongs on a page. AdminTeamRow is a separate model because it adds a field the public one must not carry; there is no such field here.

challenge_id: str = PydanticUndefined
questions: list[QuestionPublicInfo] = PydanticUndefined
gated_count: int = 0
class AdminChallengeStatsRow(ctfy.core.models.CtfyModel):
121class AdminChallengeStatsRow(CtfyModel):
122    """One row of the per-challenge admin dashboard.
123
124    Aggregates over ``InstanceRecord`` (lifetime) plus live ``InstanceState``
125    (running). Launch-duration percentiles are computed only over rows with
126    both ``requested_at`` and ``ready_at`` populated — error rows that never
127    reached READY contribute to ``errors`` / ``error_rate`` but not to p50/p95.
128
129    ⚠️ "Lifetime" was false here until the route grouped in SQL: every
130    figure came from the newest 10,000 records of the whole archive.
131    """
132
133    challenge_id: str
134    name: str = ""
135    difficulty: str = ""
136    # Spec metadata folded in so the admin table no longer has to join
137    # a second /challenges bulk fetch in the browser to show / sort /
138    # filter by these (it paginates server-side now).
139    category: str = ""
140    tags: list[str] = Field(default_factory=list)
141    question_count: int = 0
142    launches: int = 0
143    errors: int = 0
144    error_rate: float = 0.0
145    avg_launch_s: float = 0.0
146    p50_launch_s: float = 0.0
147    p95_launch_s: float = 0.0
148    solve_rate: float = 0.0
149    running_count: int = 0
150    last_error: AdminChallengeLastError | None = None
151    # Newest-first launch durations, capped at 20, used for the inline
152    # sparkline. Recorded only for successful launches.
153    recent_launch_durations: list[float] = Field(default_factory=list)

One row of the per-challenge admin dashboard.

Aggregates over InstanceRecord (lifetime) plus live InstanceState (running). Launch-duration percentiles are computed only over rows with both requested_at and ready_at populated — error rows that never reached READY contribute to errors / error_rate but not to p50/p95.

⚠️ "Lifetime" was false here until the route grouped in SQL: every figure came from the newest 10,000 records of the whole archive.

challenge_id: str = PydanticUndefined
name: str = ''
difficulty: str = ''
category: str = ''
tags: list[str] = PydanticUndefined
question_count: int = 0
launches: int = 0
errors: int = 0
error_rate: float = 0.0
avg_launch_s: float = 0.0
p50_launch_s: float = 0.0
p95_launch_s: float = 0.0
solve_rate: float = 0.0
running_count: int = 0
last_error: AdminChallengeLastError | None = None
recent_launch_durations: list[float] = PydanticUndefined
class AdminChallengeTimeseries(ctfy.core.models.CtfyModel):
330class AdminChallengeTimeseries(CtfyModel):
331    """Per-challenge bucketed health metrics for the admin drilldown.
332
333    Backs the row-level drawer on /admin/challenges that surfaces "did
334    this challenge's error rate spike at some specific time" without
335    forcing the operator to scrub through raw activity.
336    """
337
338    challenge_id: str = ""
339    window_s: int = 0
340    bucket_s: int = 0
341    buckets: list[AdminChallengeTimeseriesBucket] = Field(default_factory=list)

Per-challenge bucketed health metrics for the admin drilldown.

Backs the row-level drawer on /admin/challenges that surfaces "did this challenge's error rate spike at some specific time" without forcing the operator to scrub through raw activity.

challenge_id: str = ''
window_s: int = 0
bucket_s: int = 0
buckets: list[AdminChallengeTimeseriesBucket] = PydanticUndefined
class AdminChallengeTimeseriesBucket(ctfy.core.models.CtfyModel):
311class AdminChallengeTimeseriesBucket(CtfyModel):
312    """One bucket of the per-challenge health time-series.
313
314    Carries enough fields for the admin UI to render error-rate **and**
315    launch-duration trends from a single response — no need for the
316    frontend to issue three calls or do its own bucketing.
317
318    See :class:`AdminTimeseriesBucket` for the ``partial`` semantics.
319    """
320
321    ts: float = 0.0  # right edge, Unix seconds
322    launches: int = 0
323    errors: int = 0
324    error_rate: float = 0.0  # errors / launches; 0 when no launches
325    p50_launch_s: float = 0.0
326    p95_launch_s: float = 0.0
327    partial: bool = False

One bucket of the per-challenge health time-series.

Carries enough fields for the admin UI to render error-rate and launch-duration trends from a single response — no need for the frontend to issue three calls or do its own bucketing.

See AdminTimeseriesBucket for the partial semantics.

ts: float = 0.0
launches: int = 0
errors: int = 0
error_rate: float = 0.0
p50_launch_s: float = 0.0
p95_launch_s: float = 0.0
partial: bool = False
class AdminChangelogEntryInfo(ctfy.server.models.ChangelogEntryInfo):
50class AdminChangelogEntryInfo(ChangelogEntryInfo):
51    """The organiser's view: everything above plus the state that only
52    the person running this deployment acts on."""
53
54    notified_at: datetime | None = None
55    created_at: datetime | None = None
56    updated_at: datetime | None = None
57
58    @computed_field
59    @property
60    def is_published(self) -> bool:
61        """A ``computed_field`` — unlike its ``ChangelogEntryState``
62        twin, which is a plain property because that model feeds
63        ``to_orm``. This one exists to reach a client, so the admin table
64        does not restate ``published_at != null`` in TypeScript."""
65        return self.published_at is not None
66
67    @computed_field
68    @property
69    def is_announced(self) -> bool:
70        return self.notified_at is not None

The organiser's view: everything above plus the state that only the person running this deployment acts on.

notified_at: datetime.datetime | None = None
created_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
is_published: bool
58    @computed_field
59    @property
60    def is_published(self) -> bool:
61        """A ``computed_field`` — unlike its ``ChangelogEntryState``
62        twin, which is a plain property because that model feeds
63        ``to_orm``. This one exists to reach a client, so the admin table
64        does not restate ``published_at != null`` in TypeScript."""
65        return self.published_at is not None

A computed_field — unlike its ChangelogEntryState twin, which is a plain property because that model feeds to_orm. This one exists to reach a client, so the admin table does not restate published_at != null in TypeScript.

is_announced: bool
67    @computed_field
68    @property
69    def is_announced(self) -> bool:
70        return self.notified_at is not None
class AdminCompetitionAttachmentsResponse(ctfy.server.models.CompetitionAttachmentsResponse):
1236class AdminCompetitionAttachmentsResponse(CompetitionAttachmentsResponse):
1237    """The organiser's view: the files plus what the next upload may be."""
1238
1239    #: Whether this deployment can store a file at all. ⚠️ Served rather
1240    #: than inferred from an empty list, which reads as "nothing uploaded"
1241    #: when it means "nobody can".
1242    uploads_enabled: bool = False
1243    #: How many more files fit; ``None`` when the cap is ``0`` (unlimited).
1244    remaining: int | None = None
1245    #: The per-competition cap in force; ``0`` is unlimited.
1246    limit: int = 0
1247    #: The largest file accepted, in bytes — the hot setting as it
1248    #: resolves now, so the form states the number the route enforces.
1249    max_bytes: int = 0

The organiser's view: the files plus what the next upload may be.

uploads_enabled: bool = False
remaining: int | None = None
limit: int = 0
max_bytes: int = 0
class AdminFeedbackRow(ctfy.core.models.CtfyModel):
43class AdminFeedbackRow(CtfyModel):
44    """One row on the admin feedback audit list.
45
46    Mirrors ``SolveFeedbackState`` plus denormalised user identity for
47    triage convenience. Only ``require_admin``-gated endpoints expose
48    this shape; the public ``stats`` endpoint never includes per-user
49    fields.
50    """
51
52    user_id: str
53    user_display_name: str = ""
54    user_email: str = ""
55    challenge_id: str
56    team_id: str
57    competition_id: str
58    reaction: Reaction
59    created_at: datetime | None = None
60    updated_at: datetime | None = None

One row on the admin feedback audit list.

Mirrors SolveFeedbackState plus denormalised user identity for triage convenience. Only require_admin-gated endpoints expose this shape; the public stats endpoint never includes per-user fields.

user_id: str = PydanticUndefined
user_display_name: str = ''
user_email: str = ''
challenge_id: str = PydanticUndefined
team_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
reaction: Literal['addictive', 'mindblown', 'learned', 'goat_setter', 'overthought', 'guessy', 'brutal', 'buggy', 'infra_broken'] = PydanticUndefined
created_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
class AdminHarnessRow(ctfy.core.models.CtfyModel):
272class AdminHarnessRow(CtfyModel):
273    """One registry row as the operator reviewing it reads it.
274
275    The registry is self-service, so this table is a queue of other
276    people's images — and until it carried the three fields below, the
277    only thing on screen about an image somebody asked us to run on our
278    own hardware was its reference. "Who uploaded this", "when", and
279    "would a dispatch actually reach it" are the first three questions
280    anyone asks before approving one, and every one of them needed a
281    second lookup the page could not make.
282
283    ⚠️ **A wire model, never ``HarnessState`` with fields bolted on.**
284    State models feed ``model_dump`` → ``to_orm``, which maps every
285    dumped key to a column — so a projected ``owner_display_name`` on
286    the state model is a fresh ``no such column`` the first time
287    anything writes the row back. The two projections live here, where
288    nothing persists them.
289
290    ⚠️ **Carried by the field set, never by a list of names** — see
291    ``admin_awd._info``, which named nine of the twelve fields it shared
292    with its state model and reported every armed match as unarmed.
293    """
294
295    # -- the stored row. Every field ``HarnessState`` declares, because
296    #    the admin route has always answered with the whole thing and
297    #    narrowing it here would be a silent removal from the page.
298    id: str = ""
299    display_name: str = ""
300    image: str = ""
301    version: str = ""
302    notes: str = ""
303    active: bool = True
304    created_at: AwareDatetime | None = None
305    created_at_ts: float = 0.0
306    created_by_user_id: str = ""
307    owner_user_id: str = ""
308    review_status: RegistrationReviewStatus = "pending"
309    reviewed_by_user_id: str = ""
310    reviewed_at_ts: float = 0.0
311    review_note: str = ""
312    approved_digest: str = ""
313    source_url: WebUrl = ""
314    conformance_status: ConformanceStatus = ""
315    conformance_checked_at_ts: float = 0.0
316    conformance_digest: str = ""
317    conformance_error: str = ""
318    conformance_findings: list[dict[str, str]] = Field(default_factory=list)
319
320    # -- what the row alone cannot say -------------------------------
321    #: The account that registered this image.
322    #:
323    #: ⚠️ ``""`` here is **not** one fact, and the pair is what
324    #: separates them: with ``owner_user_id`` also blank the row is the
325    #: platform's own and there is nobody to name; with an owner set,
326    #: the account is gone and the id is all that is left. A page that
327    #: read this field alone would label a deleted user's image
328    #: "platform image" — and a platform image is exempt from review,
329    #: which is the opposite of what a row whose author vanished should
330    #: read as.
331    owner_display_name: str = ""
332    #: Which operator passed the standing verdict. Same reading as
333    #: ``owner_display_name``: paired with ``reviewed_by_user_id``.
334    reviewed_by_display_name: str = ""
335    #: Whether a dispatch would actually be sent to this image.
336    #:
337    #: ⚠️ **Composed server-side, from the one predicate that decides
338    #: it.** ``is_dispatchable`` is not a restatement of ``review_status
339    #: == "approved"`` — an approved row with no ``approved_digest`` is
340    #: refused, and so is a rejected platform row — so a client
341    #: recomputing it would eventually disagree with the gate about
342    #: whether the image runs, and disagree *silently*: the operator
343    #: sees "approved", the queue never dispatches, and nothing on the
344    #: page names the digest. Same argument ``expects_report`` makes.
345    dispatchable: bool = False

One registry row as the operator reviewing it reads it.

The registry is self-service, so this table is a queue of other people's images — and until it carried the three fields below, the only thing on screen about an image somebody asked us to run on our own hardware was its reference. "Who uploaded this", "when", and "would a dispatch actually reach it" are the first three questions anyone asks before approving one, and every one of them needed a second lookup the page could not make.

⚠️ A wire model, never HarnessState with fields bolted on. State models feed model_dump → to_orm, which maps every dumped key to a column — so a projected owner_display_name on the state model is a fresh no such column the first time anything writes the row back. The two projections live here, where nothing persists them.

⚠️ Carried by the field set, never by a list of names — see admin_awd._info, which named nine of the twelve fields it shared with its state model and reported every armed match as unarmed.

id: str = ''
display_name: str = ''
image: str = ''
version: str = ''
notes: str = ''
active: bool = True
created_at: pydantic.types.AwareDatetime | None = None
created_at_ts: float = 0.0
created_by_user_id: str = ''
owner_user_id: str = ''
review_status: Literal['pending', 'approved', 'rejected'] = 'pending'
reviewed_by_user_id: str = ''
reviewed_at_ts: float = 0.0
review_note: str = ''
approved_digest: str = ''
source_url: Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x72ea04d2a980>)] = ''
conformance_status: Literal['', 'passed', 'failed', 'error'] = ''
conformance_checked_at_ts: float = 0.0
conformance_digest: str = ''
conformance_error: str = ''
conformance_findings: list[dict[str, str]] = PydanticUndefined
owner_display_name: str = ''
reviewed_by_display_name: str = ''
dispatchable: bool = False
class AdminHealthFlags(ctfy.core.models.CtfyModel):
230class AdminHealthFlags(CtfyModel):
231    """Aggregated red-flag panels for the admin Overview "needs attention"
232    section. Every list is independently populated and may be empty."""
233
234    stuck_starting: list[AdminStuckInstance] = Field(default_factory=list)
235    recent_errors: list[AdminRecentError] = Field(default_factory=list)
236    unhealthy_nodes: list[AdminUnhealthyNode] = Field(default_factory=list)
237    silent_challenges: list[AdminSilentChallenge] = Field(default_factory=list)
238    #: ⚠️ **A list of at most one**, to match its four neighbours rather
239    #: than make the panel special-case a scalar — and so the "is
240    #: anything red?" sum stays one expression.
241    artifact_storage: list[AdminArtifactStorageFlag] = Field(default_factory=list)

Aggregated red-flag panels for the admin Overview "needs attention" section. Every list is independently populated and may be empty.

stuck_starting: list[AdminStuckInstance] = PydanticUndefined
recent_errors: list[AdminRecentError] = PydanticUndefined
unhealthy_nodes: list[AdminUnhealthyNode] = PydanticUndefined
silent_challenges: list[AdminSilentChallenge] = PydanticUndefined
artifact_storage: list[AdminArtifactStorageFlag] = PydanticUndefined
class AdminOverview(ctfy.core.models.CtfyModel):
360class AdminOverview(CtfyModel):
361    """Single aggregate payload for the admin landing page."""
362
363    nodes: AdminOverviewCounts = Field(default_factory=AdminOverviewCounts)
364    config: DeploymentConfig = Field(default_factory=DeploymentConfig)
365    capacity: int = 0
366    running_instances: int = 0
367    teams_total: int = 0
368    solves_total: int = 0
369    solves_today: int = 0
370    # User-engagement counters — the "are people actually on the platform right
371    # now?" signal the infra/challenge/team metrics above don't answer.
372    # ``users_active_24h`` is distinct humans who triggered a write action
373    # (launch / submit / solve / join / answer) in the last 24h, not raw logins
374    # (pure browsing leaves no activity row). ``users_new_24h`` is registrations
375    # in the same window. All default to 0 so the field set stays additive.
376    users_total: int = 0
377    users_active_24h: int = 0
378    users_new_24h: int = 0
379    #: Accounts whose deletion deadline has passed and which are still
380    #: here — i.e. what only a stopped ``account_deletion`` job
381    #: produces.
382    #:
383    #: ⚠️ **Overdue, never *pending*.** Every scheduled deletion is
384    #: pending until its deadline, so a pending count is healthy by
385    #: construction and says nothing about whether the sweep runs. This
386    #: is the product-side reading ``registry_usage_scan`` records the
387    #: need for: *a job's self-report is not a health signal for what it
388    #: produces*, and that job reported ``ok`` hourly for the life of a
389    #: deployment over its own failure.
390    #:
391    #: ⚠️ Defaults ``0``, which is the quiet direction — and that is the
392    #: right one here only because the alarming direction is a *standing*
393    #: claim: a producer that forgot would otherwise post a permanent
394    #: "the job is broken" banner on every healthy deployment, which is
395    #: how an operator learns to ignore it. A real stall grows the number
396    #: every tick, so the miss costs latency rather than the signal.
397    deletions_overdue: int = 0

Single aggregate payload for the admin landing page.

nodes: AdminOverviewCounts = PydanticUndefined
config: DeploymentConfig = PydanticUndefined
capacity: int = 0
running_instances: int = 0
teams_total: int = 0
solves_total: int = 0
solves_today: int = 0
users_total: int = 0
users_active_24h: int = 0
users_new_24h: int = 0
deletions_overdue: int = 0
class AdminOverviewCounts(ctfy.core.models.CtfyModel):
106class AdminOverviewCounts(CtfyModel):
107    total: int = 0
108    healthy: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

total: int = 0
healthy: int = 0
class AdminPatchSubmissionDetail(ctfy.server.models.AdminPatchSubmissionInfo):
1566class AdminPatchSubmissionDetail(AdminPatchSubmissionInfo):
1567    """One submission with the code it carried.
1568
1569    This is what keeping the bytes was *for*: a team disputes a verdict,
1570    or a checker bug is found on day two, and someone has to read what
1571    was actually submitted. Without a reader the stored blobs bought
1572    nothing.
1573    """
1574
1575    #: ``{path: base64(bytes)}``. Base64 rather than text because a
1576    #: patch target may legitimately be binary, and decoding it into a
1577    #: string would corrupt it into something that merely looks like a
1578    #: bad submission.
1579    files_b64: dict[str, str] = Field(default_factory=dict)
1580    #: ``{path: sha256}`` — an equality match across teams, which is
1581    #: what is left once uniqueness has stopped flag sharing but nothing
1582    #: stops *patch* sharing.
1583    digests: dict[str, str] = Field(default_factory=dict)
1584    #: The node's per-stage results for the latest verification, so a
1585    #: disputed `inconclusive` says *which* stage — the service never
1586    #: came up, the checker could not run, the exploit could not run —
1587    #: rather than one sentence for all three. Empty on a submission
1588    #: judged before this shipped, and on one the platform never judged.
1589    steps: list[PatchStepInfo] = Field(default_factory=list)

One submission with the code it carried.

This is what keeping the bytes was for: a team disputes a verdict, or a checker bug is found on day two, and someone has to read what was actually submitted. Without a reader the stored blobs bought nothing.

files_b64: dict[str, str] = PydanticUndefined
digests: dict[str, str] = PydanticUndefined
steps: list[PatchStepInfo] = PydanticUndefined
class AdminPatchSubmissionInfo(ctfy.server.models.PatchSubmissionInfo):
1529class AdminPatchSubmissionInfo(PatchSubmissionInfo):
1530    """One row of the organiser's defence queue.
1531
1532    The player's view plus the four things only an organiser asks:
1533    *whose* it is, and — when a verdict is disputed — which worker
1534    decided it after how many tries.
1535
1536    Still **without the code**, and here that is a stronger requirement
1537    than on the parent: this is a competition-wide listing, so shipping
1538    bodies would mean reading every stored patch of the event to render
1539    a table of statuses. The bytes live on
1540    :class:`AdminPatchSubmissionDetail`, one row at a time.
1541    """
1542
1543    team_id: str = ""
1544    user_id: str = ""
1545    #: Which worker judged it, for correlating with that node's logs.
1546    node_id: str = ""
1547    attempts: int = 0

One row of the organiser's defence queue.

The player's view plus the four things only an organiser asks: whose it is, and — when a verdict is disputed — which worker decided it after how many tries.

Still without the code, and here that is a stronger requirement than on the parent: this is a competition-wide listing, so shipping bodies would mean reading every stored patch of the event to render a table of statuses. The bytes live on AdminPatchSubmissionDetail, one row at a time.

team_id: str = ''
user_id: str = ''
node_id: str = ''
attempts: int = 0
class AdminPatchSubmissionPage(ctfy.core.models.CtfyModel):
1592class AdminPatchSubmissionPage(CtfyModel):
1593    """A page of the defence queue, with the total behind it.
1594
1595    ``total`` is its own query rather than ``len(items)`` so a pager can
1596    say "1-20 of 340" — and, with a ``status`` filter, it is also the
1597    answer to "is the queue draining".
1598    """
1599
1600    items: list[AdminPatchSubmissionInfo] = Field(default_factory=list)
1601    total: int = 0

A page of the defence queue, with the total behind it.

total is its own query rather than len(items) so a pager can say "1-20 of 340" — and, with a status filter, it is also the answer to "is the queue draining".

items: list[AdminPatchSubmissionInfo] = PydanticUndefined
total: int = 0
class AdminRecentError(ctfy.core.models.CtfyModel):
183class AdminRecentError(CtfyModel):
184    """An archived instance that ended in ``stop_reason="error"``."""
185
186    instance_id: str
187    challenge_id: str = ""
188    team_id: str = ""
189    node_id: str = ""
190    error: str = ""
191    stopped_at: float = 0.0

An archived instance that ended in stop_reason="error".

instance_id: str = PydanticUndefined
challenge_id: str = ''
team_id: str = ''
node_id: str = ''
error: str = ''
stopped_at: float = 0.0
class AdminSilentChallenge(ctfy.core.models.CtfyModel):
202class AdminSilentChallenge(CtfyModel):
203    """A challenge that has launches and submissions but no full solves —
204    a strong signal that the challenge or its flag is broken."""
205
206    challenge_id: str
207    name: str = ""
208    launches: int = 0
209    submissions: int = 0

A challenge that has launches and submissions but no full solves — a strong signal that the challenge or its flag is broken.

challenge_id: str = PydanticUndefined
name: str = ''
launches: int = 0
submissions: int = 0
class AdminSolveCell(ctfy.core.models.CtfyModel):
19class AdminSolveCell(CtfyModel):
20    """One cell in the team × challenge solve matrix.
21
22    Unsolved cells are emitted when the team has at least one submission
23    against the challenge (``attempts > 0``) so the UI can distinguish
24    "tried and failed" from "never attempted".
25    """
26
27    team_id: str
28    challenge_id: str
29    solved: bool = False
30    solved_at: datetime | None = None
31    solve_time_s: float = 0.0
32    attempts: int = 0
33    # True when this team is the first (fastest) solver of the challenge.
34    first_blood: bool = False
35    # How many of the challenge's declared questions this team has
36    # captured in-window. ``solved`` is the special case where this
37    # equals the challenge's ``milestones``.
38    #
39    # ⚠️ **A partial capture is the whole point of this field.** Before
40    # it, a team 30 milestones into a 33-milestone range was
41    # indistinguishable from one that guessed wrong three times: the
42    # cell's only progress signal was ``solved``, and a range is
43    # fully solved almost never. Measured on the real route with a team
44    # holding 4 of 5 questions -- ``solved=False, attempts=4``, the same
45    # shape four wrong guesses produce.
46    #
47    # ⚠️ ``0`` here is a true zero (nothing captured), unlike
48    # ``AdminSolveMatrixChallenge.milestones``.
49    captured: int = 0
50    # When this team last took a milestone here.
51    #
52    # ⚠️ **Not a second copy of ``solved_at``**, though on a completed
53    # challenge the two carry the same instant. They answer different
54    # questions -- "when was this finished" (null while it is not) and
55    # "when did this team last make progress" (null only if it never
56    # has) -- and on a 33-milestone range the first is null all event
57    # while the second is the only thing moving. They cannot disagree:
58    # both are derived from the same captured-flag map, and a full
59    # solve's final capture *is* what completed it.
60    last_captured_at: datetime | None = None

One cell in the team × challenge solve matrix.

Unsolved cells are emitted when the team has at least one submission against the challenge (attempts > 0) so the UI can distinguish "tried and failed" from "never attempted".

team_id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
solved: bool = False
solved_at: datetime.datetime | None = None
solve_time_s: float = 0.0
attempts: int = 0
first_blood: bool = False
captured: int = 0
last_captured_at: datetime.datetime | None = None
class AdminSolveMatrix(ctfy.core.models.CtfyModel):
 92class AdminSolveMatrix(CtfyModel):
 93    """Team × challenge solve matrix for the admin dashboard.
 94
 95    Rows (teams) and columns (challenges) pre-sorted — teams by solve count
 96    desc, challenges by category → difficulty — so the frontend can render
 97    the grid verbatim without re-sorting. Cells are sparse: only teams that
 98    have at least attempted a challenge contribute a row there.
 99    """
100
101    teams: list[AdminSolveMatrixTeam] = Field(default_factory=list)
102    challenges: list[AdminSolveMatrixChallenge] = Field(default_factory=list)
103    cells: list[AdminSolveCell] = Field(default_factory=list)

Team × challenge solve matrix for the admin dashboard.

Rows (teams) and columns (challenges) pre-sorted — teams by solve count desc, challenges by category → difficulty — so the frontend can render the grid verbatim without re-sorting. Cells are sparse: only teams that have at least attempted a challenge contribute a row there.

teams: list[AdminSolveMatrixTeam] = PydanticUndefined
challenges: list[AdminSolveMatrixChallenge] = PydanticUndefined
cells: list[AdminSolveCell] = PydanticUndefined
class AdminSolveMatrixChallenge(ctfy.core.models.CtfyModel):
71class AdminSolveMatrixChallenge(CtfyModel):
72    challenge_id: str
73    name: str = ""
74    category: str = ""
75    difficulty: str = ""
76    solves_count: int = 0
77    # How many questions the challenge declares -- the denominator for
78    # every cell's ``captured``.
79    #
80    # ⚠️ **On the column, never on the cell.** It is a property of the
81    # challenge, so putting it on all 6,600 cells would be one number
82    # copied per square with nothing keeping the copies equal.
83    #
84    # ⚠️ ``0`` means **not declared**, never "no milestones" -- a spec
85    # the reader could not resolve (a challenge retired after a solve)
86    # lands here. A reader must render no fraction at all rather than
87    # dividing by it; a `captured / 0` is what puts `NaN%` or `Infinity`
88    # on a wall in a room full of people.
89    milestones: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

challenge_id: str = PydanticUndefined
name: str = ''
category: str = ''
difficulty: str = ''
solves_count: int = 0
milestones: int = 0
class AdminSolveMatrixTeam(ctfy.core.models.CtfyModel):
63class AdminSolveMatrixTeam(CtfyModel):
64    team_id: str
65    name: str = ""
66    solved: int = 0
67    #: See :attr:`ctfy.server.models.ScoreboardEntry.anonymous`.
68    anonymous: bool = False

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

team_id: str = PydanticUndefined
name: str = ''
solved: int = 0
anonymous: bool = False
class AdminStuckInstance(ctfy.core.models.CtfyModel):
171class AdminStuckInstance(CtfyModel):
172    """A live instance stuck in STARTING for too long — likely a node-side
173    failure that didn't reconcile to ERROR cleanly."""
174
175    instance_id: str
176    challenge_id: str = ""
177    team_id: str = ""
178    node_id: str = ""
179    requested_at: float = 0.0
180    stuck_for_s: float = 0.0

A live instance stuck in STARTING for too long — likely a node-side failure that didn't reconcile to ERROR cleanly.

instance_id: str = PydanticUndefined
challenge_id: str = ''
team_id: str = ''
node_id: str = ''
requested_at: float = 0.0
stuck_for_s: float = 0.0
class AdminTaskInfo(ctfy.core.models.CtfyModel):
1047class AdminTaskInfo(CtfyModel):
1048    """One background task on ``GET /admin/tasks`` / detail. A projection
1049    of ``TaskState`` onto the wire (these routes are admin-only, so the
1050    full ``error_detail`` traceback tail is included)."""
1051
1052    id: str = ""
1053    kind: str = ""
1054    status: str = ""
1055    params: dict[str, Any] = Field(default_factory=dict)
1056    # ⚠️ Typed here and **not** on ``TaskState``. That one is a state
1057    # model feeding ``to_orm`` over a freeform JSON column whose old
1058    # rows predate every current handler; this one exists to reach a
1059    # client, which is where the type is worth anything — it is what
1060    # ``openapi-typescript`` carries into the browser, replacing a
1061    # cast and seven hand-written coercions in ``TaskResultDetails``.
1062    result: TaskResult = Field(default_factory=TaskResult)
1063    progress: float = 0.0
1064    progress_message: str = ""
1065    # Which step of *this handler's own plan* the run is inside, or ``""``
1066    # for a handler with no plan — which is every one but the three the
1067    # range studio dispatches.
1068    #
1069    # ⚠️ **`str`, and deliberately not `VerificationStepKey`**, even
1070    # though that is the only producer today. The domain is per-handler:
1071    # a second planned handler brings its own key space, so narrowing
1072    # here would publish a promise this field cannot keep, and adding
1073    # ``""`` to the union would be a member meaning *unset* — the reading
1074    # `AdminTimeseries.metric` refuses. The completeness that matters is
1075    # already typed where it belongs, on ``GauntletBuildStep.key``; a
1076    # reader **looks this value up in the plan** rather than indexing a
1077    # palette with it, so an unrecognised step marks no row live instead
1078    # of throwing.
1079    progress_step: str = ""
1080    error: str = ""
1081    error_detail: str = ""
1082    cancel_requested: bool = False
1083    created_by_user_id: str = ""
1084    created_by_name: str = ""
1085    created_at_ts: float = 0.0
1086    started_at_ts: float = 0.0
1087    finished_at_ts: float = 0.0

One background task on GET /admin/tasks / detail. A projection of TaskState onto the wire (these routes are admin-only, so the full error_detail traceback tail is included).

id: str = ''
kind: str = ''
status: str = ''
params: dict[str, typing.Any] = PydanticUndefined
result: ctfy.server.models.admin.TaskResult = PydanticUndefined
progress: float = 0.0
progress_message: str = ''
progress_step: str = ''
error: str = ''
error_detail: str = ''
cancel_requested: bool = False
created_by_user_id: str = ''
created_by_name: str = ''
created_at_ts: float = 0.0
started_at_ts: float = 0.0
finished_at_ts: float = 0.0
class AdminTaskListResponse(ctfy.core.models.CtfyModel):
1090class AdminTaskListResponse(CtfyModel):
1091    """Paginated task list."""
1092
1093    items: list[AdminTaskInfo] = Field(default_factory=list)
1094    total: int = 0
1095    offset: int = 0
1096    limit: int = 50

Paginated task list.

items: list[AdminTaskInfo] = PydanticUndefined
total: int = 0
offset: int = 0
limit: int = 50
class AdminTaskLogLine(ctfy.core.models.CtfyModel):
1099class AdminTaskLogLine(CtfyModel):
1100    """One verbose log line for a task (admin-only)."""
1101
1102    seq: int = 0
1103    ts: float = 0.0
1104    level: str = "info"
1105    message: str = ""

One verbose log line for a task (admin-only).

seq: int = 0
ts: float = 0.0
level: str = 'info'
message: str = ''
class AdminTaskLogsResponse(ctfy.core.models.CtfyModel):
1108class AdminTaskLogsResponse(CtfyModel):
1109    """A cursor page of task logs. ``next_after`` is the ``seq`` to pass as
1110    ``?after=`` on the next poll (the last item's seq, or the request's
1111    ``after`` when the page is empty — so an empty poll never rewinds the
1112    cursor)."""
1113
1114    items: list[AdminTaskLogLine] = Field(default_factory=list)
1115    next_after: int = 0

A cursor page of task logs. next_after is the seq to pass as ?after= on the next poll (the last item's seq, or the request's after when the page is empty — so an empty poll never rewinds the cursor).

items: list[AdminTaskLogLine] = PydanticUndefined
next_after: int = 0
class AdminTaskSubmitRequest(ctfy.core.models.CtfyModel):
1118class AdminTaskSubmitRequest(CtfyModel):
1119    """Body of ``POST /admin/tasks``. ``kind`` is validated against the
1120    runner's handler registry; ``params`` is the kind-specific input
1121    (e.g. ``{"competition_id": ...}`` or ``{"job": "ttl_sweep"}``)."""
1122
1123    kind: str = ""
1124    params: dict[str, Any] = Field(default_factory=dict)

Body of POST /admin/tasks. kind is validated against the runner's handler registry; params is the kind-specific input (e.g. {"competition_id": ...} or {"job": "ttl_sweep"}).

kind: str = ''
params: dict[str, typing.Any] = PydanticUndefined
class AdminTeamRow(ctfy.core.models.CtfyModel):
386class AdminTeamRow(CtfyModel):
387    """One row of the organiser's team table.
388
389    Deliberately **not** ``TeamInfo`` plus a field. ``TeamInfo`` is the
390    public projection, and standing is not public: a disqualification
391    reaches the team and the organisers, and announcing it more widely is
392    the organiser's call — the same reasoning that keeps
393    ``team_disqualified`` off the public SSE feed. Putting
394    ``disqualified`` on the public model would publish the verdict on
395    every team page, from a route nobody had to think about.
396
397    Also **not** ``RegistrationTeamRow``: that one carries every member's
398    sealed personal details and lands an audit row for the bulk read.
399    Managing a roster does not need anybody's phone number, and paying
400    that price to render a table would make the audit trail meaningless
401    by filling it with routine work.
402    """
403
404    id: str
405    name: str
406    description: str = ""
407    competition_id: str = ""
408    captain_user_id: str = ""
409    captain_display_name: str = ""
410    member_count: int = 0
411    created_at: datetime | None = None
412    review_status: str = ""
413    disqualified: bool = False
414    disqualified_at: datetime | None = None
415    #: Who took this team out, as a name rather than an id.
416    #:
417    #: ⚠️ **Resolved server-side**, the repair ``AdminHarnessRow``
418    #: records: a client mapping ids to names needs a read per row and
419    #: eventually disagrees with the server about a deleted account.
420    #:
421    #: ⚠️ **Two different blanks, and neither is the platform.** A row
422    #: disqualified before the column existed names nobody; an id
423    #: nothing resolves is an account that has gone. Both read as
424    #: *unattributed* here — the activity row keeps the id for whoever
425    #: needs it — because inventing an author is worse than an empty
426    #: cell, and ``actor_id=""`` already means an unattributable actor
427    #: everywhere else in this codebase.
428    disqualified_by_display_name: str = ""
429    disqualification_reason: str = ""
430    members: list[TeamMemberInfo] = Field(default_factory=list)

One row of the organiser's team table.

Deliberately not TeamInfo plus a field. TeamInfo is the public projection, and standing is not public: a disqualification reaches the team and the organisers, and announcing it more widely is the organiser's call — the same reasoning that keeps team_disqualified off the public SSE feed. Putting disqualified on the public model would publish the verdict on every team page, from a route nobody had to think about.

Also not RegistrationTeamRow: that one carries every member's sealed personal details and lands an audit row for the bulk read. Managing a roster does not need anybody's phone number, and paying that price to render a table would make the audit trail meaningless by filling it with routine work.

id: str = PydanticUndefined
name: str = PydanticUndefined
description: str = ''
competition_id: str = ''
captain_user_id: str = ''
captain_display_name: str = ''
member_count: int = 0
created_at: datetime.datetime | None = None
review_status: str = ''
disqualified: bool = False
disqualified_at: datetime.datetime | None = None
disqualified_by_display_name: str = ''
disqualification_reason: str = ''
members: list[TeamMemberInfo] = PydanticUndefined
class AdminTimeseries(ctfy.core.models.CtfyModel):
296class AdminTimeseries(CtfyModel):
297    """24h-by-default bucketed counts driving the admin Overview pulse charts."""
298
299    #: ⚠️ **Required, with no default.** Every other string here
300    #: defaults to ``""``, and adding ``""`` to the union to keep that
301    #: habit would hand the client a seventh member meaning *unset* —
302    #: for a field the one producer always writes from a validated
303    #: query parameter. A default would be a new claim; a required
304    #: field makes a producer that forgets fail loudly instead.
305    metric: AdminTimeseriesMetric
306    window_s: int = 0
307    bucket_s: int = 0
308    buckets: list[AdminTimeseriesBucket] = Field(default_factory=list)

24h-by-default bucketed counts driving the admin Overview pulse charts.

metric: Literal['launches', 'submissions', 'solves', 'running_instances', 'teams', 'active_users'] = PydanticUndefined
window_s: int = 0
bucket_s: int = 0
buckets: list[AdminTimeseriesBucket] = PydanticUndefined
class AdminTimeseriesBucket(ctfy.core.models.CtfyModel):
244class AdminTimeseriesBucket(CtfyModel):
245    """One bucket of an admin time-series chart.
246
247    ``ts`` is the right edge of the bucket in Unix seconds — the natural
248    point to evaluate "active right now" metrics like running_instances —
249    and the natural label to render at the right of the bucket on the
250    frontend.
251
252    ``partial=True`` marks the rightmost bucket whose right edge is
253    ``now``; it represents an in-progress window that hasn't finished
254    accumulating yet.
255
256    ⚠️ **This sentence used to describe a renderer that did not exist**
257    ("semi-transparent fill, pulsing label"), and could not: the
258    hand-written copy of this model in ``frontend/src/api.ts`` had
259    dropped the field, so nothing could read it and ``tsc`` could not
260    report the attempt. What the charts do now is plot the **finished**
261    windows and say *live* beside the headline while one is filling —
262    an in-progress count drawn at the weight of a whole window is a dip
263    at the right edge of every chart that is only the clock.
264    """
265
266    ts: float = 0.0
267    value: int = 0
268    partial: bool = False

One bucket of an admin time-series chart.

ts is the right edge of the bucket in Unix seconds — the natural point to evaluate "active right now" metrics like running_instances — and the natural label to render at the right of the bucket on the frontend.

partial=True marks the rightmost bucket whose right edge is now; it represents an in-progress window that hasn't finished accumulating yet.

⚠️ This sentence used to describe a renderer that did not exist ("semi-transparent fill, pulsing label"), and could not: the hand-written copy of this model in frontend/src/api.ts had dropped the field, so nothing could read it and tsc could not report the attempt. What the charts do now is plot the finished windows and say live beside the headline while one is filling — an in-progress count drawn at the weight of a whole window is a dip at the right edge of every chart that is only the clock.

ts: float = 0.0
value: int = 0
partial: bool = False
AdminTimeseriesMetric = typing.Literal['launches', 'submissions', 'solves', 'running_instances', 'teams', 'active_users']
class AdminTokenRevokeResponse(ctfy.core.models.CtfyModel):
587class AdminTokenRevokeResponse(CtfyModel):
588    """Result of ``POST /admin/tokens/{token_id}/revoke``.
589
590    The revoke half of the trace surface: ``token trace`` resolves a
591    leaked ``pf_…`` to its ``token_id`` (marking the *live* one in a
592    rotation lineage), and this ends that row. Owner-revoke is
593    account-scoped by design, so an operator holding a leaked secret —
594    especially one minting synthetic **agent** entrants, whose owner is
595    a user nobody can log in as — had no way to end it short of a direct
596    DB write. This is that door.
597
598    ⚠️ **A miss here is a 404, unlike ``token-trace``'s 200.** The trace
599    route takes a *secret* and "not one we minted" is the reassuring
600    answer; this route takes a *``token_id``* the operator already read
601    off a trace, so an id that resolves to nothing is a wrong id, and a
602    404 says exactly that.
603
604    ⚠️ **Revocation is idempotent, not an error.** Re-revoking a row
605    that is already revoked returns ``already_revoked=True`` at 200 with
606    its original ``revoked_at`` — the compare-and-set in the backend is
607    what makes a second caller safe, and the audit event fires only on
608    the transition.
609    """
610
611    token_id: str
612    owner_user_id: str = ""
613    owner_display_name: str = ""
614    kind: TokenKind
615    label: str = ""
616    already_revoked: bool = False
617    revoked_at: datetime | None = None

Result of POST /admin/tokens/{token_id}/revoke.

The revoke half of the trace surface: token trace resolves a leaked pf_… to its token_id (marking the live one in a rotation lineage), and this ends that row. Owner-revoke is account-scoped by design, so an operator holding a leaked secret — especially one minting synthetic agent entrants, whose owner is a user nobody can log in as — had no way to end it short of a direct DB write. This is that door.

⚠️ A miss here is a 404, unlike token-trace's 200. The trace route takes a secret and "not one we minted" is the reassuring answer; this route takes a token_id the operator already read off a trace, so an id that resolves to nothing is a wrong id, and a 404 says exactly that.

⚠️ Revocation is idempotent, not an error. Re-revoking a row that is already revoked returns already_revoked=True at 200 with its original revoked_at — the compare-and-set in the backend is what makes a second caller safe, and the audit event fires only on the transition.

token_id: str = PydanticUndefined
owner_user_id: str = ''
owner_display_name: str = ''
kind: Literal['user', 'agent', 'fine_grained'] = PydanticUndefined
label: str = ''
already_revoked: bool = False
revoked_at: datetime.datetime | None = None
class AdminTrafficInstanceRow(ctfy.core.models.CtfyModel):
415class AdminTrafficInstanceRow(CtfyModel):
416    """One instance × team × challenge row for the admin traffic dashboard."""
417
418    instance_id: str
419    team_id: str
420    team_name: str = ""
421    challenge_id: str = ""
422    name: str = ""
423    status: str = ""
424    request_count: int = 0
425    started_at: float = 0.0
426    stopped_at: float = 0.0
427    # ``True`` if the instance is still running (request_count is approximate
428    # — refreshed only when the dashboard re-fetches).
429    live: bool = False

One instance × team × challenge row for the admin traffic dashboard.

instance_id: str = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = ''
challenge_id: str = ''
name: str = ''
status: str = ''
request_count: int = 0
started_at: float = 0.0
stopped_at: float = 0.0
live: bool = False
class AdminTrafficSummary(ctfy.core.models.CtfyModel):
432class AdminTrafficSummary(CtfyModel):
433    """Top-level response for ``GET /admin/traffic/summary``.
434
435    ``teams`` and ``total_requests`` are sums over **every** archived
436    instance; ``instances`` lists only the newest archived ones (plus the
437    live boxes), because a row per instance ever run is a payload that
438    grows without bound.
439
440    ⚠️ **All three used to be computed from that same newest page**, under
441    a module docstring saying "every archived instance": a team whose
442    traffic was older had none, and ``total_requests`` stopped growing at
443    the cap. The totals are summed in SQL now, which is what makes the
444    list a *page* of something larger — so the response says how large.
445    """
446
447    teams: list[AdminTrafficTeamRow] = Field(default_factory=list)
448    instances: list[AdminTrafficInstanceRow] = Field(default_factory=list)
449    total_requests: int = 0
450    #: Every archived instance with a team plus every live one — what
451    #: ``instances`` is the newest part of, and what the page's instance
452    #: count shows. ``len(instances)`` is not a count of anything.
453    #:
454    #: ⚠️ Defaults ``0`` so the field set stays additive, as
455    #: ``AdminOverview``'s counters do; a server that predates the field
456    #: sends none, so a reader released separately from the server (the
457    #: CLI) takes ``max(instances_total, len(instances))`` rather than
458    #: reporting zero beside a list of rows.
459    instances_total: int = 0

Top-level response for GET /admin/traffic/summary.

teams and total_requests are sums over every archived instance; instances lists only the newest archived ones (plus the live boxes), because a row per instance ever run is a payload that grows without bound.

⚠️ All three used to be computed from that same newest page, under a module docstring saying "every archived instance": a team whose traffic was older had none, and total_requests stopped growing at the cap. The totals are summed in SQL now, which is what makes the list a page of something larger — so the response says how large.

teams: list[AdminTrafficTeamRow] = PydanticUndefined
instances: list[AdminTrafficInstanceRow] = PydanticUndefined
total_requests: int = 0
instances_total: int = 0
class AdminTrafficTeamRow(ctfy.core.models.CtfyModel):
400class AdminTrafficTeamRow(CtfyModel):
401    """Per-team aggregate row for the admin traffic dashboard.
402
403    Sums HTTP request counts across every archived instance the team has
404    ever launched, plus the live count for any instances the team is
405    still running.
406    """
407
408    team_id: str
409    team_name: str = ""
410    instance_count: int = 0
411    request_count: int = 0
412    last_activity_at: float = 0.0

Per-team aggregate row for the admin traffic dashboard.

Sums HTTP request counts across every archived instance the team has ever launched, plus the live count for any instances the team is still running.

team_id: str = PydanticUndefined
team_name: str = ''
instance_count: int = 0
request_count: int = 0
last_activity_at: float = 0.0
class AdminUnhealthyNode(ctfy.core.models.CtfyModel):
194class AdminUnhealthyNode(CtfyModel):
195    node_id: str
196    display_name: str = ""
197    url: str = ""
198    last_heartbeat_ts: float = 0.0
199    downtime_s: float = 0.0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

node_id: str = PydanticUndefined
display_name: str = ''
url: str = ''
last_heartbeat_ts: float = 0.0
downtime_s: float = 0.0
class AdminUserInfo(ctfy.server.models.UserInfo):
179class AdminUserInfo(UserInfo):
180    """Admin-only view of a user. Carries the privilege tier and the
181    most recent role-change audit fields."""
182
183    email: str = ""
184    role: UserRole = "user"
185    promoted_by: str | None = None
186    promoted_at: datetime | None = None
187    #: What to call the account in ``promoted_by``. Resolved in bulk at
188    #: the route by `ctfy.server.entity_labels.operator_labels` — the
189    #: operator's answer, so it ends at the address when there is no
190    #: display name, because the reader is asking *who granted this*
191    #: and an address is how they go and ask about it.
192    #:
193    #: ⚠️ **Empty is never the id**, the contract
194    #: `InstanceInfo.team_name` states: the id is on the wire beside
195    #: this, so echoing it would make "the account is gone" render
196    #: identically to a resolved row and leave the client with no way
197    #: to tell that it should fall back.
198    promoted_by_display_name: str = ""
199    # OAuth providers the user has bound (e.g. ``["github", "google"]``),
200    # so the admin user list can surface which SSO accounts are linked.
201    providers: list[str] = Field(default_factory=list)
202    #: When this account was taken off public standings; ``None`` = it
203    #: counts. Here so the admin list can show which accounts are the
204    #: deployment's own without a second read.
205    standings_excluded_at: datetime | None = None
206    #: When this account's self-requested deletion becomes final;
207    #: ``None`` = it never asked. `DELETE /me` stamps it and revokes
208    #: every token, so a pending account is *locked* — which on this
209    #: page is otherwise indistinguishable from one that simply stopped
210    #: playing. Here for ``standings_excluded_at``'s reason: the admin
211    #: list can show it without a second read.
212    #:
213    #: ⚠️ **The instant, never a ``pending: bool``.** An operator
214    #: arrives with two questions — *is this pending* and *when does it
215    #: go* — and the instant answers both where a flag answers one; a
216    #: flag beside it would be two claims about one fact, free to
217    #: disagree.
218    deletion_effective_at: datetime | None = None
219    #: Whether this account has anything outstanding to prove — composed
220    #: server-side from :func:`~ctfy.server.email_verification.user_is_verified`,
221    #: the predicate the write gate itself reads.
222    #:
223    #: ⚠️ **Not ``email_verified_at is not None``**, and the difference is
224    #: a whole population rather than an edge case: an account with no
225    #: address is *outside* the question, not unproved, and three kinds
226    #: of them sit in this very table (every synthetic agent entrant, the
227    #: platform's own service account, and a human on a generic-OIDC
228    #: deployment configured with ``oidc_trust_email_verified=false``).
229    #: The raw column would badge all of them as blocked while the server
230    #: lets every one of their writes through.
231    #:
232    #: ⚠️ Defaults **False**, the loud direction: this model has two
233    #: producers, and a producer that forgets makes every row on the page
234    #: claim it is blocked — visible in a minute — rather than reporting
235    #: a frozen account as fine. The *client* reads ``=== false``, so an
236    #: older server that sends neither field badges nobody.
237    email_verified: bool = False
238    #: When proof of the address first existed, or ``None``.
239    #:
240    #: ⚠️ **Beside the flag, not instead of it**, because ``None`` is two
241    #: different facts and only the pair separates them: with
242    #: ``email_verified`` false it means *never proved* (the actionable
243    #: state), and with it true it means *there is no address to prove*.
244    #: A reader with one field can render two states out of three.
245    email_verified_at: datetime | None = None
246    #: Who vouched for the address, when the account did not prove it
247    #: itself — the id of the super-admin who used
248    #: ``POST /admin/users/{user_id}/verify-email``. ``""`` is the
249    #: account's own proof.
250    #:
251    #: ⚠️ **Read with the two fields above, never alone.** ``""`` means
252    #: *proved by the account* only where ``email_verified`` is true and
253    #: ``email_verified_at`` is set; on an unproved account it says
254    #: nothing. A reader that branches on this one column answers "the
255    #: account proved it" about an account that has proved nothing.
256    #:
257    #: ⚠️ Published so the table can say *vouched* rather than
258    #: *verified*. Without it an operator's assertion renders
259    #: identically to a mailed code, which is the whole objection the
260    #: column exists to answer — a distinction stored and then withheld
261    #: from the one page that would show it is a distinction nobody has.
262    email_verified_by: str = ""
263    #: What to call the account in ``email_verified_by``. Same resolver
264    #: and the same three states as ``promoted_by_display_name``.
265    #:
266    #: ⚠️ The pair is what makes the column readable, not the name
267    #: alone: a blank here **beside a set** ``email_verified_by`` is an
268    #: operator whose account has since gone, which is a different
269    #: sentence from *nobody vouched* (a blank ``email_verified_by``)
270    #: and the one an audit is actually asking about.
271    email_verified_by_display_name: str = ""

Admin-only view of a user. Carries the privilege tier and the most recent role-change audit fields.

email: str = ''
role: Literal['user', 'admin', 'super_admin'] = 'user'
promoted_by: str | None = None
promoted_at: datetime.datetime | None = None
promoted_by_display_name: str = ''
providers: list[str] = PydanticUndefined
standings_excluded_at: datetime.datetime | None = None
deletion_effective_at: datetime.datetime | None = None
email_verified: bool = False
email_verified_at: datetime.datetime | None = None
email_verified_by: str = ''
email_verified_by_display_name: str = ''
class AffiliationInfo(ctfy.core.models.CtfyModel):
145class AffiliationInfo(CtfyModel):
146    """The institution one account publishes, as a stranger reads it.
147
148    Three fields, and what is **absent** is most of the point.
149
150    ⚠️ **The address never travels.** ``users.affiliation_email`` names
151    which of the account's proved addresses the badge derives from and
152    that choice is the person's own business; a ``.edu`` address is
153    often a student number, and the original ask — show the address
154    beside the logo — is a different and much larger disclosure than the
155    one that was agreed. `test_the_badge_publishes_no_address` is the
156    pin, because a field added to this model later would leak it in
157    silence and every surface would go on rendering correctly.
158
159    ⚠️ **No ``verified_at``, and the reason is that the column does not
160    mean what a date on a badge would say.** ``user_emails.verified_at``
161    fills and never overwrites (`rank_solve`'s rule), so it is when
162    proof *first* existed rather than when it was last checked —
163    publishing it as *"verified September 2019"* invites a reader to
164    conclude *stale*, which is a claim this platform cannot make: what
165    it knows is that the address is attached **now** and was proved
166    once, and the badge's own sentence already carries the first half.
167    A field that supports a wrong reading is worse than an absent one.
168
169    ⚠️ **No ``kind`` and no ``url``.** Both are on the catalogue row and
170    neither has a reader here: the chip draws a logo keyed on ``domain``
171    or a monogram from ``short_name``, and its title names the
172    institution. A published field with no reader is the recorded shape
173    (`EvalRunState.transcript_path`), so they arrive with whatever
174    renders them.
175
176    ⚠️ **``names`` crosses as a map**, resolved in the browser, for
177    `InstitutionInfo`'s reason — the language is a client-side
178    preference switched without a reload.
179    """
180
181    #: The catalogue key. The **institution's** domain, never the
182    #: account's address: it is what a logo file is named after
183    #: (``frontend/public/institutions/<domain>.svg``) and it identifies
184    #: a public organisation rather than a person.
185    domain: str = ""
186    names: dict[str, str] = Field(default_factory=dict)
187    #: What a monogram chip draws when the repository ships no logo.
188    short_name: str = ""

The institution one account publishes, as a stranger reads it.

Three fields, and what is absent is most of the point.

⚠️ The address never travels. users.affiliation_email names which of the account's proved addresses the badge derives from and that choice is the person's own business; a .edu address is often a student number, and the original ask — show the address beside the logo — is a different and much larger disclosure than the one that was agreed. test_the_badge_publishes_no_address is the pin, because a field added to this model later would leak it in silence and every surface would go on rendering correctly.

⚠️ No verified_at, and the reason is that the column does not mean what a date on a badge would say. user_emails.verified_at fills and never overwrites (rank_solve's rule), so it is when proof first existed rather than when it was last checked — publishing it as "verified September 2019" invites a reader to conclude stale, which is a claim this platform cannot make: what it knows is that the address is attached now and was proved once, and the badge's own sentence already carries the first half. A field that supports a wrong reading is worse than an absent one.

⚠️ No kind and no url. Both are on the catalogue row and neither has a reader here: the chip draws a logo keyed on domain or a monogram from short_name, and its title names the institution. A published field with no reader is the recorded shape (EvalRunState.transcript_path), so they arrive with whatever renders them.

⚠️ names crosses as a map, resolved in the browser, for InstitutionInfo's reason — the language is a client-side preference switched without a reload.

domain: str = ''
names: dict[str, str] = PydanticUndefined
short_name: str = ''
class AffiliationSelection(ctfy.core.models.CtfyModel):
191class AffiliationSelection(CtfyModel):
192    """The account's own view of its badge: the choice and its effect.
193
194    ⚠️ **Both halves, because either alone is ambiguous.** ``email``
195    carries the address chosen and ``affiliation`` what strangers
196    actually see, and the interesting state is the one where they
197    disagree: an address that was picked and has since lost its proof,
198    or sits at a domain the operator has not named, reads as *chosen,
199    showing nothing* — which a card holding only ``affiliation`` cannot
200    tell from *nothing chosen*, and the two have opposite repairs
201    (prove it / ask the operator to name the domain, against pick one).
202
203    ⚠️ **The address is here and never on :class:`AffiliationInfo`.**
204    This model answers to the account about itself; that one is what a
205    stranger reads.
206    """
207
208    #: The chosen address, verbatim as the account typed it. ``""`` is
209    #: *no badge*, which is the default and the opt-out.
210    email: str = ""
211    #: What the badge resolves to right now, or ``None``.
212    affiliation: AffiliationInfo | None = None

The account's own view of its badge: the choice and its effect.

⚠️ Both halves, because either alone is ambiguous. email carries the address chosen and affiliation what strangers actually see, and the interesting state is the one where they disagree: an address that was picked and has since lost its proof, or sits at a domain the operator has not named, reads as chosen, showing nothing — which a card holding only affiliation cannot tell from nothing chosen, and the two have opposite repairs (prove it / ask the operator to name the domain, against pick one).

⚠️ The address is here and never on AffiliationInfo. This model answers to the account about itself; that one is what a stranger reads.

email: str = ''
affiliation: AffiliationInfo | None = None
class AgentActionRow(ctfy.core.models.CtfyModel):
1342class AgentActionRow(CtfyModel):
1343    """One published entrant's current action, as a room reads it.
1344
1345    ⚠️ **One line, scrubbed and cut, never the conversation.**
1346    ``ctfy.core.agent_action`` picks the newest tool call (or, failing
1347    that, the newest thing the model said), runs it through
1348    ``ctfy.core.redaction`` and caps it at ``MAX_ACTION_CHARS``. The
1349    three rules ``AgentPulseBeat`` states still hold — what changed is
1350    that two of them are answered by the scrub and the third by the
1351    author's own ``TeamState.wall_published_at``.
1352
1353    ⚠️ **An author who never opted in has no row here at all**, rather
1354    than a row with a blank line: a blank is what a published entrant
1355    with nothing to show looks like, and a wall that rendered both the
1356    same would be publishing the *existence* of a decision nobody made.
1357    """
1358
1359    team_id: str = ""
1360    #: ⚠️ **Projected**, like every other public surface naming a squad;
1361    #: ``""`` beside a set id is ``InstanceInfo.team_name``'s third
1362    #: state and never the id as a fallback.
1363    team_name: str = ""
1364    anonymous: bool = False
1365    #: Which model wrote this line. ⚠️ Cleared for a withheld entrant
1366    #: for ``anonymise_awd_board``'s reason — the attribution is
1367    #: identity, so a row that hid the name and kept it would be
1368    #: decorative.
1369    model: str = ""
1370    #: ⚠️ **Required, no default.** ``none`` is a real answer (the turn
1371    #: produced nothing a room can read), so a fourth value meaning
1372    #: *this server did not say* would be a state with no reader —
1373    #: ``AgentPulseBeat.trouble``'s rule.
1374    kind: AgentActionKind
1375    #: The scrubbed line, already cut. ⚠️ Plain text on the wire and
1376    #: plain text on the wall: ``LlmCallBody``'s rule for prose an
1377    #: untrusted image wrote, and a projector is the worst place to
1378    #: relax it.
1379    line: str = ""
1380    #: When the call this was read from **finished** —
1381    #: ``AgentPulseBeat.at_ts``'s floor, repeated because it is the
1382    #: only instant there is.
1383    at_ts: float = 0.0
1384    #: Was there a transcript to read at all? ⚠️ **Separate from an
1385    #: empty ``line``**: retention reclaims a body and leaves the row,
1386    #: so *the agent said nothing* and *the transcript is gone* are
1387    #: opposite facts that render identically at three metres, and this
1388    #: is the only field that can tell them apart.
1389    available: bool = False
1390    #: Whether this entrant's **full conversation** is publicly linkable
1391    #: (B2): the author gave the second consent and the entrant is not
1392    #: anonymous. ⚠️ Not the same as being on the wall — an author may
1393    #: put a line here and withhold the transcript, so the landing feed
1394    #: shows the click-through only when this is set, and a "not
1395    #: published" note otherwise. Defaults ``False``: an older server, or
1396    #: an author who only opted into the line, offers no transcript.
1397    transcript_public: bool = False
1398    #: The call whose line this row shows, so the feed can link straight
1399    #: to its full conversation. ⚠️ **Set only when ``transcript_public``**
1400    #: — the row carries a linkable id exactly when the feed renders a
1401    #: link, and ``""`` for a withheld, anonymous or un-opted-in entrant,
1402    #: so ``anonymise_agent_actions`` has nothing extra to blank. The
1403    #: transcript route re-checks every gate, so a leaked id still meets
1404    #: ``transcript_not_published`` — this only spares the honest reader a
1405    #: dead click.
1406    call_id: str = ""

One published entrant's current action, as a room reads it.

⚠️ One line, scrubbed and cut, never the conversation. ctfy.core.agent_action picks the newest tool call (or, failing that, the newest thing the model said), runs it through ctfy.core.redaction and caps it at MAX_ACTION_CHARS. The three rules AgentPulseBeat states still hold — what changed is that two of them are answered by the scrub and the third by the author's own TeamState.wall_published_at.

⚠️ An author who never opted in has no row here at all, rather than a row with a blank line: a blank is what a published entrant with nothing to show looks like, and a wall that rendered both the same would be publishing the existence of a decision nobody made.

team_id: str = ''
team_name: str = ''
anonymous: bool = False
model: str = ''
kind: ctfy.core.agent_action.AgentActionKind = PydanticUndefined
line: str = ''
at_ts: float = 0.0
available: bool = False
transcript_public: bool = False
call_id: str = ''
class AgentActionsPage(ctfy.core.models.CtfyModel):
1409class AgentActionsPage(CtfyModel):
1410    """What this event's published agents are doing.
1411
1412    ⚠️ **Four empty states, and each field exists to separate two of
1413    them.** An empty ``rows`` means one of: this deployment keeps no
1414    transcript index (``indexing``), nobody has entered an agent
1415    (``entrants``), nobody's author opted in (``published``), or
1416    everybody opted in and no call has finished yet (all three set,
1417    ``rows`` empty). Collapsing any pair sends the room — or the
1418    organiser they ask — to look at the wrong thing.
1419    """
1420
1421    competition_id: str = ""
1422    #: ``LlmCallPage.indexing``'s reason: with no gateway there is no
1423    #: index at all, so an empty page means the opposite of "nobody ran
1424    #: an agent". Rendered before the rows by the one reader there is.
1425    indexing: bool = True
1426    #: Agent entrants in this event, opted in or not. ⚠️ The
1427    #: denominator, so ``published`` is readable as a *share* rather
1428    #: than as a bare count — and so "nobody entered" cannot be
1429    #: mistaken for "nobody agreed".
1430    entrants: int = 0
1431    #: How many of those authors said yes. ⚠️ Published even when it
1432    #: equals ``entrants``: a room that cannot tell a full opt-in from
1433    #: a coincidence is being told something the page does not know.
1434    published: int = 0
1435    rows: list[AgentActionRow] = Field(default_factory=list)
1436    #: ⚠️ A route that reads at most ``agent_actions.MAX_ROWS``
1437    #: entrants owes the number it could have drawn, or a complete
1438    #: answer and a truncated one read identically — the rule
1439    #: ``test_a_list_route_says_how_many`` pins one surface along.
1440    #: This is ``published``'s ceiling, not the page's.
1441    max_rows: int = 0

What this event's published agents are doing.

⚠️ Four empty states, and each field exists to separate two of them. An empty rows means one of: this deployment keeps no transcript index (indexing), nobody has entered an agent (entrants), nobody's author opted in (published), or everybody opted in and no call has finished yet (all three set, rows empty). Collapsing any pair sends the room — or the organiser they ask — to look at the wrong thing.

competition_id: str = ''
indexing: bool = True
entrants: int = 0
published: int = 0
rows: list[AgentActionRow] = PydanticUndefined
max_rows: int = 0
class AgentEntrantInfo(ctfy.core.models.CtfyModel):
1061class AgentEntrantInfo(CtfyModel):
1062    """Whose agent an entry is, for a reader of the leaderboard.
1063
1064    ``entrant_kind`` says a row is not a person; this says *which* agent
1065    and *whose*. Two rows both reading "agent" with nothing behind them
1066    is a ranking nobody can check — and this is the surface where the
1067    checking happens, because a model result nobody can attribute or
1068    reproduce is a claim rather than a measurement.
1069
1070    ⚠️ **Absent for the platform's own eval entrant, not just for
1071    people.** Those harness rows have no owner, so there is no author to
1072    link to and no repository to open; offering one would point a reader
1073    at nobody. The board still marks the row as an agent — the two facts
1074    are separate.
1075
1076    ⚠️ **The image reference is deliberately not here.** It names a
1077    registry path somebody chose privately; ``source_url`` is what its
1078    author opted into publishing, and the digest is what makes a run
1079    reproducible without disclosing where the bytes live.
1080    """
1081
1082    # ⚠️ ``id`` for the entity's own id, ``owner_user_id`` for the
1083    # foreign key — the wire-naming rule, and the frontend reads both
1084    # verbatim (one as a label, one as a link target).
1085    id: str
1086    display_name: str = ""
1087    # What the author called this build. A tag, so a mutable pointer.
1088    version: str = ""
1089    owner_user_id: str = ""
1090    owner_display_name: str = ""
1091    # ⚠️ ``""`` means *not published*, never "closed source": the board
1092    # renders no link and makes no claim about the agent.
1093    source_url: str = ""
1094    # ⚠️ **What actually ran**, as opposed to what it was called. An
1095    # operator approves a digest and the node pulls that digest, so on a
1096    # leaderboard this is the field that makes a result reproducible —
1097    # ``version`` is only what its author typed.
1098    image_digest: str = ""

Whose agent an entry is, for a reader of the leaderboard.

entrant_kind says a row is not a person; this says which agent and whose. Two rows both reading "agent" with nothing behind them is a ranking nobody can check — and this is the surface where the checking happens, because a model result nobody can attribute or reproduce is a claim rather than a measurement.

⚠️ Absent for the platform's own eval entrant, not just for people. Those harness rows have no owner, so there is no author to link to and no repository to open; offering one would point a reader at nobody. The board still marks the row as an agent — the two facts are separate.

⚠️ The image reference is deliberately not here. It names a registry path somebody chose privately; source_url is what its author opted into publishing, and the digest is what makes a run reproducible without disclosing where the bytes live.

id: str = PydanticUndefined
display_name: str = ''
version: str = ''
owner_user_id: str = ''
owner_display_name: str = ''
source_url: str = ''
image_digest: str = ''
class AgentPulseBeat(ctfy.core.models.CtfyModel):
1230class AgentPulseBeat(CtfyModel):
1231    """One **finished** model call, as a projector reads it.
1232
1233    ⚠️ **No content, and that is three independent rules rather than a
1234    preference.** `LlmCallBody` is attacker-authored text; a relayed
1235    conversation carries the challenge's minted answer, which on an AWD
1236    gamebox is the current round's flag; and the body belongs to its
1237    author rather than to their squad, while a wall belongs to the room.
1238    So the metadata *is* the content here.
1239
1240    ⚠️ **No money and no key id.** `LlmCallRow` carries both for an
1241    operator; a public page showing what an entrant's agent costs
1242    discloses the operator's rates and the entrant's budget, and neither
1243    is what a room came to see.
1244    """
1245
1246    #: When the call **finished**. ⚠️ There is no other instant to give:
1247    #: the gateway composes a record once, at completion, so no cadence
1248    #: anywhere can place a conversation in flight — `run_llm_call_drain`
1249    #: states that floor and the surfaces say it out loud rather than
1250    #: implying otherwise by updating quickly.
1251    at_ts: float = 0.0
1252    #: What the model wrote, which is the beat's weight on the wall.
1253    output_tokens: int = 0
1254    #: ⚠️ **Rides along so a wall can tell "0 tokens" from "nobody
1255    #: knows"** — ``extract_usage`` preserves that distinction on purpose
1256    #: and a chart drawing both as a flat tick destroys it at the last
1257    #: metre. ``LlmCallRow.usage_known``'s reason, one surface along.
1258    usage_known: bool = False
1259    upstream_ms: int = 0
1260    #: ⚠️ **Required, no default.** The members are different things to
1261    #: go and look at, so a sixth value meaning *this server did not
1262    #: say* would be a state with no reader and no remedy —
1263    #: ``QuestionCoverageRow.state``'s rule. One producer writes this and
1264    #: it always knows.
1265    trouble: PulseTrouble

One finished model call, as a projector reads it.

⚠️ No content, and that is three independent rules rather than a preference. LlmCallBody is attacker-authored text; a relayed conversation carries the challenge's minted answer, which on an AWD gamebox is the current round's flag; and the body belongs to its author rather than to their squad, while a wall belongs to the room. So the metadata is the content here.

⚠️ No money and no key id. LlmCallRow carries both for an operator; a public page showing what an entrant's agent costs discloses the operator's rates and the entrant's budget, and neither is what a room came to see.

at_ts: float = 0.0
output_tokens: int = 0
usage_known: bool = False
upstream_ms: int = 0
trouble: ctfy.core.agent_pulse.PulseTrouble = PydanticUndefined
class AgentPulseLane(ctfy.core.models.CtfyModel):
1268class AgentPulseLane(CtfyModel):
1269    """One entrant's stream of beats, plus whether they are still going.
1270
1271    ⚠️ **The lane exists for a team with a running agent and no beats
1272    yet**, which is why this is a lane list rather than a flat beat list
1273    a client groups. An agent that has started and not yet finished a
1274    call is a real state and the one a room asks about first; derived
1275    from the beats alone it is indistinguishable from an entrant who
1276    never entered.
1277    """
1278
1279    team_id: str = ""
1280    #: ⚠️ **Projected**, like every other public surface naming a squad.
1281    #: ``""`` beside a set id is the third state ``InstanceInfo.team_name``
1282    #: records — never the id as a fallback.
1283    team_name: str = ""
1284    anonymous: bool = False
1285    #: Which model this entrant's agent is driving. ⚠️ Cleared for a
1286    #: withheld entrant for ``anonymise_awd_board``'s reason: the
1287    #: attribution is identity, so a lane that hid the name and kept it
1288    #: would be decorative.
1289    model: str = ""
1290    #: Is one of this entrant's runs still going? ⚠️ **The whole reason a
1291    #: quiet lane is readable.** Without it, "the agent is thinking",
1292    #: "the agent is finished" and "the drain is broken" are the same gap
1293    #: on the right-hand edge, and they are three different things to do.
1294    working: bool = False
1295    beats: list[AgentPulseBeat] = Field(default_factory=list)

One entrant's stream of beats, plus whether they are still going.

⚠️ The lane exists for a team with a running agent and no beats yet, which is why this is a lane list rather than a flat beat list a client groups. An agent that has started and not yet finished a call is a real state and the one a room asks about first; derived from the beats alone it is indistinguishable from an entrant who never entered.

team_id: str = ''
team_name: str = ''
anonymous: bool = False
model: str = ''
working: bool = False
beats: list[AgentPulseBeat] = PydanticUndefined
class AgentPulsePage(ctfy.core.models.CtfyModel):
1298class AgentPulsePage(CtfyModel):
1299    """What a competition's agents have been asking a model.
1300
1301    ⚠️ **``indexing`` rides along for ``LlmCallPage``'s reason**: an empty
1302    page means *either* nothing was relayed *or* this deployment keeps no
1303    index, and those call for opposite actions. Rendered before the lanes
1304    by the one reader there is.
1305    """
1306
1307    competition_id: str = ""
1308    indexing: bool = True
1309    lanes: list[AgentPulseLane] = Field(default_factory=list)
1310    #: Every matching call, against the windowed beats above — a route
1311    #: that takes a ``limit`` owes one, or a complete answer and a
1312    #: truncated one read identically.
1313    total: int = 0

What a competition's agents have been asking a model.

⚠️ indexing rides along for LlmCallPage's reason: an empty page means either nothing was relayed or this deployment keeps no index, and those call for opposite actions. Rendered before the lanes by the one reader there is.

competition_id: str = ''
indexing: bool = True
lanes: list[AgentPulseLane] = PydanticUndefined
total: int = 0
class AnnouncementCreate(ctfy.core.models.CtfyModel):
14class AnnouncementCreate(CtfyModel):
15    """Request body for ``POST /admin/announcements``.
16
17    Time fields accept ISO 8601 strings; empty means "no bound" (live
18    immediately / never expires). Severity defaults to ``info`` so a
19    minimal "title + body" payload still validates.
20    """
21
22    title: str = Field(min_length=1, max_length=200)
23    body: str = Field(default="", max_length=20000)
24    severity: AnnouncementSeverity = "info"
25    starts_at: datetime | None = None
26    ends_at: datetime | None = None

Request body for POST /admin/announcements.

Time fields accept ISO 8601 strings; empty means "no bound" (live immediately / never expires). Severity defaults to info so a minimal "title + body" payload still validates.

title: str = PydanticUndefined
body: str = ''
severity: Literal['info', 'warning', 'critical'] = 'info'
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
class AnnouncementInfo(ctfy.core.models.CtfyModel):
39class AnnouncementInfo(CtfyModel):
40    """Response shape — mirrors ``AnnouncementState`` field-for-field."""
41
42    id: str
43    title: str
44    body: str
45    severity: AnnouncementSeverity
46    starts_at: datetime | None = None
47    ends_at: datetime | None = None
48    created_at: datetime | None = None
49    updated_at: datetime | None = None
50    created_by: str
51    created_by_name: str

Response shape — mirrors AnnouncementState field-for-field.

id: str = PydanticUndefined
title: str = PydanticUndefined
body: str = PydanticUndefined
severity: Literal['info', 'warning', 'critical'] = PydanticUndefined
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
created_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
created_by: str = PydanticUndefined
created_by_name: str = PydanticUndefined
class AnnouncementUpdate(ctfy.core.models.CtfyModel):
29class AnnouncementUpdate(CtfyModel):
30    """PATCH body — every field optional, ``None`` means "leave alone"."""
31
32    title: str | None = Field(default=None, min_length=1, max_length=200)
33    body: str | None = Field(default=None, max_length=20000)
34    severity: AnnouncementSeverity | None = None
35    starts_at: datetime | None = None
36    ends_at: datetime | None = None

PATCH body — every field optional, None means "leave alone".

title: str | None = None
body: str | None = None
severity: Optional[Literal['info', 'warning', 'critical']] = None
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
class AnswerTraceHit(ctfy.core.models.CtfyModel):
329class AnswerTraceHit(CtfyModel):
330    """One instance that was minted the traced answer.
331
332    ``live`` distinguishes a still-running instance (matched against the
333    in-memory minted map) from an archived one (matched against the
334    persisted digest). Both are authoritative; the flag only says
335    whether the instance can still be inspected or stopped.
336    """
337
338    instance_id: str = ""
339    question_id: str = ""
340    team_id: str = ""
341    team_name: str = ""
342    challenge_id: str = ""
343    competition_id: str = ""
344    live: bool = False
345    started_at: float = 0.0
346    stopped_at: float = 0.0

One instance that was minted the traced answer.

live distinguishes a still-running instance (matched against the in-memory minted map) from an archived one (matched against the persisted digest). Both are authoritative; the flag only says whether the instance can still be inspected or stopped.

instance_id: str = ''
question_id: str = ''
team_id: str = ''
team_name: str = ''
challenge_id: str = ''
competition_id: str = ''
live: bool = False
started_at: float = 0.0
stopped_at: float = 0.0
class AnswerTraceRequest(ctfy.core.models.CtfyModel):
315class AnswerTraceRequest(CtfyModel):
316    """Body for ``POST /admin/answer-trace``.
317
318    The answer travels in the body rather than a query parameter
319    because a live flag in a URL lands in every access log, proxy
320    cache, and browser history between the operator and the app.
321
322    Accepted in either the ``FLAG{...}`` or bare form — the server
323    normalises both to one digest, matching how submissions grade.
324    """
325
326    answer: str = Field(min_length=1, max_length=512)

Body for POST /admin/answer-trace.

The answer travels in the body rather than a query parameter because a live flag in a URL lands in every access log, proxy cache, and browser history between the operator and the app.

Accepted in either the FLAG{...} or bare form — the server normalises both to one digest, matching how submissions grade.

answer: str = PydanticUndefined
class AnswerTraceResponse(ctfy.core.models.CtfyModel):
389class AnswerTraceResponse(CtfyModel):
390    """Result of ``POST /admin/answer-trace``.
391
392    ``hits`` is normally empty or length 1. More than one means two
393    instances genuinely drew the same value — astronomically unlikely
394    for a 128-bit token, but reported rather than silently collapsed
395    because attributing a leak to the wrong team is worse than
396    reporting ambiguity.
397
398    ``traceable_records`` is the number of archived records that carry
399    a digest at all. Instances archived before answer fingerprinting
400    shipped have none, so an empty ``hits`` on a low count means "this
401    predates tracing", not "this flag was never minted here" — the
402    distinction matters when an organiser is deciding whether to act.
403    """
404
405    answer_hash: str = ""
406    hits: list[AnswerTraceHit] = Field(default_factory=list)
407    searched_live: int = 0
408    searched_records: int = 0
409    traceable_records: int = 0

Result of POST /admin/answer-trace.

hits is normally empty or length 1. More than one means two instances genuinely drew the same value — astronomically unlikely for a 128-bit token, but reported rather than silently collapsed because attributing a leak to the wrong team is worse than reporting ambiguity.

traceable_records is the number of archived records that carry a digest at all. Instances archived before answer fingerprinting shipped have none, so an empty hits on a low count means "this predates tracing", not "this flag was never minted here" — the distinction matters when an organiser is deciding whether to act.

answer_hash: str = ''
hits: list[AnswerTraceHit] = PydanticUndefined
searched_live: int = 0
searched_records: int = 0
traceable_records: int = 0
class ArchiveStoreReport(ctfy.core.models.CtfyModel):
251class ArchiveStoreReport(CtfyModel):
252    """The platform's instance-archive store and its last retention pass (#1714).
253
254    Held in process memory by ``run_instance_archive_retention`` and read
255    by ``GET /admin/instance-storage``: absent until the first pass after
256    a restart, which the page shows as *not measured yet*.
257    """
258
259    measured_at: float = 0.0
260    status: PassStatus = PassStatus.OK
261    root: str = ""
262    instances: int = 0
263    total_bytes: int = 0
264    capture_bytes: int = 0
265    metadata_bytes: int = 0
266    #: Held by finished instances — what the two windows can reach.
267    finished_bytes: int = 0
268    #: What the windows in force made due on this pass.
269    due_bytes: int = 0
270    capture_retention_s: int = 0
271    archive_retention_s: int = 0
272    reclaimed_instances: int = 0
273    reclaimed_bytes: int = 0
274    failed: int = 0
275    kept: dict[KeepReason, int] = Field(default_factory=dict)
276    skipped: dict[SkipReason, int] = Field(default_factory=dict)
277    error: str = ""

The platform's instance-archive store and its last retention pass (#1714).

Held in process memory by run_instance_archive_retention and read by GET /admin/instance-storage: absent until the first pass after a restart, which the page shows as not measured yet.

measured_at: float = 0.0
status: ctfy.core.instance_disk_retention.PassStatus = <PassStatus.OK: 'ok'>
root: str = ''
instances: int = 0
total_bytes: int = 0
capture_bytes: int = 0
metadata_bytes: int = 0
finished_bytes: int = 0
due_bytes: int = 0
capture_retention_s: int = 0
archive_retention_s: int = 0
reclaimed_instances: int = 0
reclaimed_bytes: int = 0
failed: int = 0
kept: dict[ctfy.core.instance_disk_retention.KeepReason, int] = PydanticUndefined
skipped: dict[ctfy.core.instance_disk_retention.SkipReason, int] = PydanticUndefined
error: str = ''
class AttachmentList(ctfy.core.models.CtfyModel):
558class AttachmentList(CtfyModel):
559    """Response payload for ``GET /challenges/{id}/attachments``.
560
561    Same data as ``ChallengeInfo.attachments`` but addressable directly
562    so pre-launch UI / agent tooling can fetch it without paging through
563    the catalog. Order matches the on-disk sort.
564    """
565
566    files: list[AttachmentInfo] = Field(default_factory=list)

Response payload for GET /challenges/{id}/attachments.

Same data as ChallengeInfo.attachments but addressable directly so pre-launch UI / agent tooling can fetch it without paging through the catalog. Order matches the on-disk sort.

files: list[ctfy.core.target.AttachmentInfo] = PydanticUndefined
class AuditFinding(ctfy.core.models.CtfyModel):
414class AuditFinding(CtfyModel):
415    """One rule's complaint about an uploaded tree."""
416
417    #: `META007`, `STRUCT011`, `EXPLOIT007` — the id an author can look
418    #: up, which is why it travels rather than being folded into prose.
419    rule: str
420    #: `error` or `warning` as the audit spells them. Deliberately not
421    #: narrowed: `Severity` is the audit's enum and this crosses a
422    #: subprocess boundary as text, so a member a newer auditor grows
423    #: must reach the page rather than 500 on the way.
424    severity: str = ""
425    #: Relative to the benchmark directory, or empty for a finding about
426    #: the tree as a whole.
427    path: str = ""
428    #: ``None`` rather than ``0``: there is no line 0, so a falsy
429    #: default would render as a location the finding is not about.
430    line: int | None = None
431    message: str = ""

One rule's complaint about an uploaded tree.

rule: str = PydanticUndefined
severity: str = ''
path: str = ''
line: int | None = None
message: str = ''
class AuthTokenResponse(ctfy.core.models.CtfyModel):
115class AuthTokenResponse(CtfyModel):
116    """Returned by ``POST /auth/register`` and ``POST /auth/login``.
117
118    The plaintext user token is returned directly (not via fragment) since
119    these endpoints are called by first-party XHR, not redirects.
120    """
121
122    token: str
123    redirect_to: str = "/"

Returned by POST /auth/register and POST /auth/login.

The plaintext user token is returned directly (not via fragment) since these endpoints are called by first-party XHR, not redirects.

token: str = PydanticUndefined
redirect_to: str = '/'
class AuthorReputation(ctfy.core.models.CtfyModel):
34class AuthorReputation(CtfyModel):
35    """How players received an uploader's published challenges (plan P3.5).
36
37    Informational, and for reviewers and admins only: it orders the review
38    queue when asked and gates nothing — review is the control, never a
39    reward. Read through the curve that prices authoring on a
40    ``peer_authoring`` board, full marks for a challenge most entrants
41    fail and somebody finishes, and averaged over the uploads that have a
42    rate.
43    """
44
45    #: The mean curve value over the uploads with a rate, between the
46    #: curve's floor and 1. ``None`` is *no reading* — nothing published,
47    #: or nothing tried by enough entrants to rate — never a zero.
48    score: float | None = None
49    #: How many published uploads had enough entrants to carry a rate.
50    priced: int = 0
51    #: How many published uploads were read at all.
52    published: int = 0

How players received an uploader's published challenges (plan P3.5).

Informational, and for reviewers and admins only: it orders the review queue when asked and gates nothing — review is the control, never a reward. Read through the curve that prices authoring on a peer_authoring board, full marks for a challenge most entrants fail and somebody finishes, and averaged over the uploads that have a rate.

score: float | None = None
priced: int = 0
published: int = 0
class AuthoredAuditReport(ctfy.core.models.CtfyModel):
434class AuthoredAuditReport(CtfyModel):
435    """What `ctfy-admin challenge audit` says about one upload.
436
437    ⚠️ **An approval was a person reading code with no machine verdict
438    beside it.** The audit is the tool the author is told to run and the
439    platform never ran it, so the reviewer's evidence was the files —
440    and META/STRUCT/EXPLOIT findings are precisely what reading nine
441    files does not reliably catch.
442
443    ⚠️ ``outcome`` defaults to ``failed`` and the counts to zero, so a
444    producer that forgets to fill this in reports *the audit did not
445    run* rather than *the tree is clean*. The default must never be the
446    reassuring claim.
447    """
448
449    outcome: AuditOutcome = "failed"
450    #: Counted over every finding the audit reported, **not** over the
451    #: cut list below: totals that shrank with the page would tell a
452    #: reviewer the tree is cleaner than it is.
453    errors: int = 0
454    warnings: int = 0
455    findings: list[AuditFinding] = Field(default_factory=list)
456    #: True when `findings` was cut. Said rather than implied — an
457    #: oldest-surviving finding that cannot say it is not the last is
458    #: the shape `traffic.live.retained` records.
459    truncated: bool = False

What ctfy-admin challenge audit says about one upload.

⚠️ An approval was a person reading code with no machine verdict beside it. The audit is the tool the author is told to run and the platform never ran it, so the reviewer's evidence was the files — and META/STRUCT/EXPLOIT findings are precisely what reading nine files does not reliably catch.

⚠️ outcome defaults to failed and the counts to zero, so a producer that forgets to fill this in reports the audit did not run rather than the tree is clean. The default must never be the reassuring claim.

outcome: Literal['ok', 'timed_out', 'failed'] = 'failed'
errors: int = 0
warnings: int = 0
findings: list[AuditFinding] = PydanticUndefined
truncated: bool = False
class AuthoredAutoSmoke(ctfy.core.models.CtfyModel):
358class AuthoredAutoSmoke(CtfyModel):
359    """What the platform did about smoking one upload automatically.
360
361    ``docs/community-authoring-plan.md`` P2.2. A fact about the *upload
362    request*, not about the row — which is why it rides only on the write
363    response and is never stored: the verdict the run produces lands on
364    the row as ``smoke_verdict``, like any other smoke run's.
365    """
366
367    outcome: AuthoredAutoSmokeOutcome
368    #: The person's runs per rolling day. ``None`` where no budget was
369    #: consulted (``off``, ``no_pool``, ``nothing_to_run``).
370    budget: int | None = None
371    #: Runs left in the window after this upload's. ``queued`` only.
372    remaining: int | None = None
373    #: When the oldest run in the window leaves it, which is when the
374    #: next upload can be smoked. ``budget_spent`` only.
375    available_at: AwareDatetime | None = None

What the platform did about smoking one upload automatically.

docs/community-authoring-plan.md P2.2. A fact about the upload request, not about the row — which is why it rides only on the write response and is never stored: the verdict the run produces lands on the row as smoke_verdict, like any other smoke run's.

outcome: Literal['queued', 'budget_spent', 'off', 'no_pool', 'nothing_to_run'] = PydanticUndefined
budget: int | None = None
remaining: int | None = None
available_at: pydantic.types.AwareDatetime | None = None
class AuthoredBuildCapability(ctfy.core.models.CtfyModel):
518class AuthoredBuildCapability(CtfyModel):
519    """Whether an approval on this deployment can become a live challenge.
520
521    ⚠️ **Composed server-side, for `expects_report`'s reason.** Both
522    facts — is anything marked, does the push exist — could be shipped
523    separately and `&&`-ed in the browser, and then two clients disagree
524    about whether an approval means anything. One answer, one composer.
525
526    ⚠️ **This exists because the page is where an operator forms the
527    belief.** `PlatformSettingInfo.in_force` records the same shape one
528    layer along: a setting that resolves ``true`` and does nothing is
529    read as a gate that is running. Here the equivalent is an operator
530    pressing Approve and going to look for a challenge that is not in
531    front of anybody.
532
533    ⚠️ ``can_build`` defaults **False** and the client reads it as such:
534    an older server that does not send this field has no untrusted-build
535    axis at all, so the honest reading of its silence is *no*. That is
536    the opposite default from most gates here, and deliberately — the
537    open direction would have the page promise a deployment capability
538    nothing implements.
539    """
540
541    #: True only when a marked node exists **and** the push is built.
542    #: Today the second half is always false; the field is not a
543    #: constant because the first half is what an operator changes.
544    can_build: bool = False
545    #: Why not. ``""`` only when ``can_build`` is true.
546    blocked_reason: AuthoredBuildBlockedReason = "not_implemented"
547    #: How many healthy nodes carry the label. ``0`` beside
548    #: ``no_untrusted_build_node`` is the actionable case: mark one.
549    #: Counted rather than named, because a node id is an operator's
550    #: internal topology and the queue page is not where it is read.
551    marked_node_count: int = 0

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

⚠️ Composed server-side, for expects_report's reason. Both facts — is anything marked, does the push exist — could be shipped separately and &&-ed in the browser, and then two clients disagree about whether an approval means anything. One answer, one composer.

⚠️ This exists because the page is where an operator forms the belief. PlatformSettingInfo.in_force records the same shape one layer along: a setting that resolves true and does nothing is read as a gate that is running. Here the equivalent is an operator pressing Approve and going to look for a challenge that is not in front of anybody.

⚠️ can_build defaults False and the client reads it as such: an older server that does not send this field has no untrusted-build axis at all, so the honest reading of its silence is no. That is the opposite default from most gates here, and deliberately — the open direction would have the page promise a deployment capability nothing implements.

can_build: bool = False
blocked_reason: Literal['', 'no_untrusted_build_node', 'not_implemented'] = 'not_implemented'
marked_node_count: int = 0
class AuthoredChallengeFileBody(ctfy.core.models.CtfyModel):
790class AuthoredChallengeFileBody(CtfyModel):
791    """One file's contents, for the operator who has to read it.
792
793    ⚠️ **This is the point of the whole queue.** An approval is a person
794    saying "I read somebody else's code and it may run on our nodes";
795    without a way to read it the verdict is a rubber stamp, and the
796    button would be worse than no button. The compose gate
797    (`ctfy.core.authored_compose`) rules on the declarative surface at
798    the door — a ``Dockerfile``'s ``RUN`` is exactly what no key list can
799    see, and this is where a human sees it.
800
801    ⚠️ **Plain text, never markup, and never a raw byte stream.**
802    ``LlmCallBody``'s rule for prose an untrusted image wrote applies
803    with more force here, because this text was written to be run: a
804    response the browser may sniff as HTML is stored XSS on an admin
805    page, served from the platform's own origin.
806
807    ⚠️ **``binary`` and ``truncated`` are separate facts and both are
808    published.** Decoding a compiled artifact with replacement
809    characters would show a reviewer mojibake and let them believe they
810    had read the file; a silent cut would let them believe they had read
811    all of it. Neither is a refusal — a reviewer who cannot open one
812    file must still be able to rule on the tree.
813    """
814
815    path: str
816    #: The file's real size, always — so a truncated body still says how
817    #: much was not shown.
818    size_bytes: int = 0
819    #: Decoded text, empty when ``binary``.
820    text: str = ""
821    binary: bool = False
822    truncated: bool = False

One file's contents, for the operator who has to read it.

⚠️ This is the point of the whole queue. An approval is a person saying "I read somebody else's code and it may run on our nodes"; without a way to read it the verdict is a rubber stamp, and the button would be worse than no button. The compose gate (ctfy.core.authored_compose) rules on the declarative surface at the door — a Dockerfile's RUN is exactly what no key list can see, and this is where a human sees it.

⚠️ Plain text, never markup, and never a raw byte stream. LlmCallBody's rule for prose an untrusted image wrote applies with more force here, because this text was written to be run: a response the browser may sniff as HTML is stored XSS on an admin page, served from the platform's own origin.

⚠️ binary and truncated are separate facts and both are published. Decoding a compiled artifact with replacement characters would show a reviewer mojibake and let them believe they had read the file; a silent cut would let them believe they had read all of it. Neither is a refusal — a reviewer who cannot open one file must still be able to rule on the tree.

path: str = PydanticUndefined
size_bytes: int = 0
text: str = ''
binary: bool = False
truncated: bool = False
class AuthoredChallengeFileEntry(ctfy.core.models.CtfyModel):
690class AuthoredChallengeFileEntry(CtfyModel):
691    """One file in an upload, as the detail page lists it."""
692
693    path: str
694    size_bytes: int = 0
695    #: Where this file runs, if anywhere (plan P2.3): ``node`` — the
696    #: platform builds or runs it on its own machines — or ``player`` —
697    #: an executable or archive players download and run on theirs.
698    #: ``""`` is *runs nowhere*. Structural, from
699    #: `ctfy.core.execution_surface`: the compose file and the files'
700    #: first bytes.
701    executes: ExecutionSurfaceState = ""

One file in an upload, as the detail page lists it.

path: str = PydanticUndefined
size_bytes: int = 0
executes: Literal['', 'node', 'player'] = ''
class AuthoredChallengeFileRequest(ctfy.core.models.CtfyModel):
825class AuthoredChallengeFileRequest(CtfyModel):
826    """Which file of a stored tree to read (#1929).
827
828    ⚠️ **In the body, never in the URL.** The file routes took the member
829    path as a ``{path:path}`` segment, and a campus WAF in front of one
830    deployment blocks any request whose URL path ends in ``.ini`` — and
831    decodes ``%2E`` before matching, so encoding is no way round it. A
832    reviewer there could not read the ``php.ini`` they were reviewing. A
833    path-sniffing filter does not read a JSON body.
834
835    ⚠️ **A dictionary key, never a filesystem path**: nothing joins it to a
836    directory (`_file` looks it up in the stored tree), so it is not a
837    ``PathSegment`` sink. The bound is only a bound on the request.
838    """
839
840    path: str = Field(min_length=1, max_length=4096)

Which file of a stored tree to read (#1929).

⚠️ In the body, never in the URL. The file routes took the member path as a {path:path} segment, and a campus WAF in front of one deployment blocks any request whose URL path ends in .ini — and decodes %2E before matching, so encoding is no way round it. A reviewer there could not read the php.ini they were reviewing. A path-sniffing filter does not read a JSON body.

⚠️ A dictionary key, never a filesystem path: nothing joins it to a directory (_file looks it up in the stored tree), so it is not a PathSegment sink. The bound is only a bound on the request.

path: str = PydanticUndefined
class AuthoredChallengeInfo(ctfy.server.models.AuthoredChallengeRow):
378class AuthoredChallengeInfo(AuthoredChallengeRow):
379    """One upload plus the paths in it — what a *write* answers with.
380
381    ⚠️ **A subclass rather than a second model**, which is what keeps
382    `admin_awd._info`'s defect from being re-earned: every shared field
383    reaches both shapes by construction, so a field added to the listing
384    row cannot be forgotten here. The one composer builds the base and
385    exactly one caller adds ``files``.
386
387    ⚠️ **Only where the tree is already in hand.** The upload route just
388    wrote the row, so the paths are free there; the listing holds
389    :class:`ctfy.core.authored_challenges.AuthoredChallengeSummary`,
390    which by construction has no tree — and an empty ``files`` on a
391    listing row would be a *claim* that the upload has no files, which
392    is the shape-5 reading this split exists to avoid.
393    """
394
395    #: Sorted paths relative to the benchmark directory.
396    files: list[str] = Field(default_factory=list)
397    #: Whether this upload was smoked on the quarantine pool as it arrived
398    #: (P2.2). ⚠️ ``None`` is *not reported* — an older server, or a
399    #: queueing step that failed after the upload was stored — never
400    #: *not smoked*: the withheld value must not be a new claim.
401    auto_smoke: AuthoredAutoSmoke | None = None

One upload plus the paths in it — what a write answers with.

⚠️ A subclass rather than a second model, which is what keeps admin_awd._info's defect from being re-earned: every shared field reaches both shapes by construction, so a field added to the listing row cannot be forgotten here. The one composer builds the base and exactly one caller adds files.

⚠️ Only where the tree is already in hand. The upload route just wrote the row, so the paths are free there; the listing holds ctfy.core.authored_challenges.AuthoredChallengeSummary, which by construction has no tree — and an empty files on a listing row would be a claim that the upload has no files, which is the shape-5 reading this split exists to avoid.

files: list[str] = PydanticUndefined
auto_smoke: AuthoredAutoSmoke | None = None
class AuthoredChallengeReviewRequest(ctfy.core.models.CtfyModel):
843class AuthoredChallengeReviewRequest(CtfyModel):
844    """An operator's verdict on one upload.
845
846    ⚠️ **A verdict can be revised**, as on ``review_harness``: a route
847    that only moved forward would leave a wrongly-approved tree
848    approved until somebody deleted the row.
849    """
850
851    status: ReviewStatus
852    #: What the author reads. Required on a rejection — see
853    #: ``review_authored_challenge``.
854    note: str = ""

An operator's verdict on one upload.

⚠️ A verdict can be revised, as on review_harness: a route that only moved forward would leave a wrongly-approved tree approved until somebody deleted the row.

status: Literal['pending', 'approved', 'rejected'] = PydanticUndefined
note: str = ''
class AuthoredChallengeRow(ctfy.server.models.AuthoredDeclarations):
178class AuthoredChallengeRow(AuthoredDeclarations):
179    """One upload as the author's own listing shows it.
180
181    ⚠️ **The bytes do not travel, and now neither do the paths.** A tree
182    is up to ``MAX_TOTAL_BYTES`` and a listing is every upload the team
183    has ever made, so this model decides what that page costs. It used
184    to carry ``files: list[str]`` and the route therefore had to select
185    ``content_b64`` to produce them — measured at the 32 MiB ceiling of
186    the time, 85 MB of peak per upload and 427 MB for five, to render a
187    file **count**.
188
189    ⚠️ **The paths kept a reader, and finding it is what split this in
190    two.** The first version of this change deleted ``files`` outright,
191    on a measurement of the *production* surfaces: the page and the CLI
192    take a length and nothing reads a name. That population was the
193    wrong one — `test_mcp_authoring_tools` asserts on the **paths**
194    through the pack/unpack round trip, and its own comment says why a
195    count cannot stand in ("a packer that wrote the wrapping component
196    twice, or none, still sends three files"). So the names live on
197    :class:`AuthoredChallengeInfo`, which the **upload** answers with,
198    where the tree is already in hand and costs nothing.
199    """
200
201    id: str
202    #: What the author called it, and what they address it by on every
203    #: surface. The id is minted, so this is the only half of the old
204    #: id that was theirs.
205    #:
206    #: ⚠️ **Unique per ``(competition_id, team_id)`` and no further.**
207    #: The id was globally unique, so one team taking ``vault`` denied
208    #: that name to every other team on the platform; the route had a
209    #: refusal for it and does not need one now.
210    slug: str = ""
211    #: ``metadata.yaml``'s own ``name:``, read at ingest. ``""`` means
212    #: the metadata did not say, and a reader falls back to `slug` —
213    #: never to the id, which is what this whole split removes from
214    #: anybody's screen.
215    title: str = ""
216    competition_id: str
217    team_id: str
218    #: Who pressed upload. Present so a captain can see which team-mate
219    #: sent this; the team is what gets the credit.
220    #:
221    #: ⚠️ **A uuid is not a reader**, which is why the field beside it
222    #: exists: this comment named an audience for the whole life of the
223    #: feature and no surface rendered either half. `AdminHarnessRow`
224    #: records the same finding one queue over — "the author is a uuid",
225    #: and a table of uuids is a table nobody triages.
226    user_id: str
227    #: The uploader's own display name, composed server-side.
228    #:
229    #: ⚠️ **Never their email**, which is where this parts company with
230    #: `admin_authoring._display_name`: that one falls back to the
231    #: address because its reader is an operator deciding whether to run
232    #: somebody's code, and `TeamMemberInfo` — the roster this same
233    #: squad already reads — publishes a display name and no address.
234    #: One shared helper would impose the operator's answer on a page
235    #: every team-mate can open.
236    #:
237    #: ⚠️ ``""`` means *no name to show* — the account is gone, or that
238    #: person never set one. The id travels beside it, so a reader can
239    #: still say "somebody on this team"; collapsing the two into the id
240    #: is what `_display_name`'s own docstring refuses.
241    author_display_name: str = ""
242    submitted_at: AwareDatetime | None = None
243    #: How many files the tree holds, and how many bytes they decode
244    #: to — so an author can tell a tree that arrived from one that
245    #: arrived empty without downloading it back.
246    #:
247    #: ⚠️ **These are stored columns, and they are what let the listing
248    #: stop reading the tree.** Producing them from ``content_b64`` is
249    #: what made the author's own listing select every byte their team
250    #: had ever uploaded: measured at a 32 MiB ``MAX_TOTAL_BYTES``, 85 MB
251    #: of peak for one upload and 427 MB for five, to render a count.
252    file_count: int = 0
253    total_bytes: int = 0
254    review_status: ReviewStatus = "pending"
255    reviewed_at: AwareDatetime | None = None
256    #: Why it was turned down. ``""`` while nobody has ruled.
257    review_note: str = ""
258    #: What running this tree's own reference exploit on a worker found,
259    #: or ``""`` if nobody has run one.
260    #:
261    #: ⚠️ **The author is the audience, and this is the half the audit
262    #: cannot produce.** `AuthoredAuditReport` reads files, so a
263    #: reference exploit that returns ``{}`` passes it clean — and an
264    #: unsolved board is what a hard challenge looks like too. Whoever
265    #: wrote the exploit is the only person who can fix that, and until
266    #: now the verdict reached an operator's task record and stopped.
267    #:
268    #: ⚠️ **Defaults to `""`, which claims nothing.** Any named member
269    #: would tell an author something about a run that never happened,
270    #: and `unsolved` in particular would accuse them of it.
271    smoke_verdict: AuthoredSmokeState = ""
272    #: When that run finished. Freshness — whether a verdict exists is
273    #: ``smoke_verdict``'s question, not a second field's.
274    smoke_at: AwareDatetime | None = None
275    #: A smoke run of this tree that has not produced its verdict yet:
276    #: ``queued`` waits its turn, ``running`` is on a worker now (leftover
277    #: L1). ``smoke_verdict`` above is still the last *finished* run's.
278    #:
279    #: ⚠️ **Why it exists**: the upload response says a run is queued, and
280    #: the listing then read "Not run" for as long as the run waited — the
281    #: one state that sends an author to upload again, spending another
282    #: run. Read off the task table (`ctfy.core.smoke_runs`), never stored.
283    #:
284    #: ⚠️ ``""`` is *no run in flight*, and also what a producer that did
285    #: not ask says: the display the row had before this field, never a
286    #: promise that a verdict is coming.
287    smoke_in_flight: AuthoredSmokeInFlightState = ""
288    #: Whether players can actually launch it: the tree is in this
289    #: deployment's catalog, read off the disk.
290    #:
291    #: ⚠️ **``review_status`` was answering three questions and could
292    #: only answer one.** An author read *approved* for a challenge
293    #: never published, for one that is live, and — once the removal
294    #: verb landed — for one taken back off the shelf. That is
295    #: `authored_publish`'s own recorded defect ("an operator who reads
296    #: it as *it is live* goes looking for a challenge that is not
297    #: there") pointed at the person who asks why their challenge is
298    #: not on the board.
299    #:
300    #: ⚠️ **Not the same as being in the event's challenge set**, and
301    #: the copy must not blur them: this is the catalog, while whether
302    #: an organiser curated it into ``challenge_ids`` is the field
303    #: below. A challenge can be live in the catalog and in no
304    #: competition.
305    #:
306    #: ⚠️ Defaults **False**, the loud direction and the admin row's:
307    #: an older server, or a producer that did not ask, reports *not
308    #: live*, which sends an author to ask rather than to assume.
309    in_catalog: bool = False
310    #: Where it stands with **this event's** challenge set — the step
311    #: after the one above, and the last one before entrants see it.
312    #:
313    #: ⚠️ **The sentence this replaced named a reader that does not
314    #: exist for most of this field's population.** ``in_catalog``'s
315    #: comment used to send the reader to
316    #: `AuthoredReceptionState.not_curated`, and measured, that route
317    #: answers ``{"scored": false, "rows": []}`` for every scoring rule
318    #: but ``peer_authoring`` — `authoring_enabled` is its own axis, so
319    #: an organiser collecting uploads on a jeopardy event leaves the
320    #: author with **no** surface for this at all.
321    #:
322    #: ⚠️ And the asymmetry ran the wrong way: `AdminAuthoredChallengeRow`
323    #: has carried `curation` since the operator's queue learned it,
324    #: composed from the competition row and therefore rule-independent.
325    #: The person who can *act* on it was told and the person *waiting*
326    #: on them was not.
327    #:
328    #: ⚠️ Same producer as the operator's (`server/authoring_curation.py`),
329    #: so the two cannot answer one upload differently — which is the
330    #: whole reason that module exists.
331    #:
332    #: ⚠️ Defaults ``not_curated``, the unfinished-looking reading: a
333    #: producer that did not ask must not tell an author their challenge
334    #: is in front of entrants.
335    curation: AuthoredCurationState = "not_curated"
336    #: The competitions **other than this upload's own** whose curated
337    #: set lists it — where it went after the event it was written for
338    #: (plan P1.5). ``curation`` above stays the own-event answer.
339    #:
340    #: ⚠️ From the **same producer** for both audiences
341    #: (`authoring_curation.curated_elsewhere`), `curation`'s rule — but
342    #: the author's copy names only competitions the author may see, so
343    #: an upload is not how a hidden event's name reaches somebody it is
344    #: hidden from. The operator's copy names all of them.
345    #:
346    #: ⚠️ Empty by default, and empty is a real answer: nothing curated
347    #: it anywhere else. A bucket competition never appears, because
348    #: nothing is curated into a set resolved from a catalog directory.
349    curated_elsewhere: list[AuthoredCurationElsewhere] = Field(default_factory=list)
350    #: The revision proposed for this upload and not yet applied — waiting,
351    #: approved or turned down — or ``None`` when there is none (plan P3.1).
352    #:
353    #: ⚠️ ``None`` is also what a producer that did not ask says, and it
354    #: claims nothing: the row is the live version either way.
355    revision: AuthoredRevisionRow | None = None

One upload as the author's own listing shows it.

⚠️ The bytes do not travel, and now neither do the paths. A tree is up to MAX_TOTAL_BYTES and a listing is every upload the team has ever made, so this model decides what that page costs. It used to carry files: list[str] and the route therefore had to select content_b64 to produce them — measured at the 32 MiB ceiling of the time, 85 MB of peak per upload and 427 MB for five, to render a file count.

⚠️ The paths kept a reader, and finding it is what split this in two. The first version of this change deleted files outright, on a measurement of the production surfaces: the page and the CLI take a length and nothing reads a name. That population was the wrong one — test_mcp_authoring_tools asserts on the paths through the pack/unpack round trip, and its own comment says why a count cannot stand in ("a packer that wrote the wrapping component twice, or none, still sends three files"). So the names live on AuthoredChallengeInfo, which the upload answers with, where the tree is already in hand and costs nothing.

id: str = PydanticUndefined
slug: str = ''
title: str = ''
competition_id: str = PydanticUndefined
team_id: str = PydanticUndefined
user_id: str = PydanticUndefined
author_display_name: str = ''
submitted_at: pydantic.types.AwareDatetime | None = None
file_count: int = 0
total_bytes: int = 0
review_status: Literal['pending', 'approved', 'rejected'] = 'pending'
reviewed_at: pydantic.types.AwareDatetime | None = None
review_note: str = ''
smoke_verdict: Union[Literal[''], Literal['solved', 'unsolved', 'estate_failed', 'malformed', 'errored']] = ''
smoke_at: pydantic.types.AwareDatetime | None = None
smoke_in_flight: Union[Literal[''], Literal['queued', 'running']] = ''
in_catalog: bool = False
curation: Literal['curated', 'not_curated', 'auto_bucket'] = 'not_curated'
curated_elsewhere: list[AuthoredCurationElsewhere] = PydanticUndefined
revision: AuthoredRevisionRow | None = None
class AuthoredCurationElsewhere(ctfy.core.models.CtfyModel):
67class AuthoredCurationElsewhere(CtfyModel):
68    """Another competition an upload has been curated into.
69
70    ``docs/community-authoring-plan.md`` P1.5: the workshop → arena step
71    curates an upload into a competition that is not its own, and the
72    author's page has to be able to say it graduated. The competition's
73    own id and title, because the title is what a person reads and the
74    id is what a client links to.
75    """
76
77    competition_id: str
78    #: ``""`` only if the competition had no title — every reader then
79    #: renders the id.
80    competition_title: str = ""

Another competition an upload has been curated into.

docs/community-authoring-plan.md P1.5: the workshop → arena step curates an upload into a competition that is not its own, and the author's page has to be able to say it graduated. The competition's own id and title, because the title is what a person reads and the id is what a client links to.

competition_id: str = PydanticUndefined
competition_title: str = ''
AuthoredCurationState = typing.Literal['curated', 'not_curated', 'auto_bucket']
class AuthoredDeclarations(ctfy.core.models.CtfyModel):
 83class AuthoredDeclarations(CtfyModel):
 84    """What an author declared about an upload under the contributor
 85    agreement, and which agreement they accepted
 86    (`docs/community-authoring-plan.md` P1.3).
 87
 88    The base of every row model that describes an upload, so a field
 89    added here reaches the author's listing and the operator's queue
 90    alike — the composers carry by field-set intersection.
 91
 92    ⚠️ ``""`` on ``license``, ``provenance`` and ``ai_assisted`` is
 93    **nobody said** — an upload from before the agreement existed, or to
 94    an event that asks for none — and a reader takes the strictest
 95    meaning: platform only, not known. It is never shown as an answer.
 96    The two grants default to their refusal instead: no evaluation use,
 97    anonymous credit.
 98    """
 99
100    #: One of six licences (decision 9), or ``""`` for none declared.
101    license: AuthoredLicenseState = ""
102    #: ``original`` or ``port``, or ``""`` for none declared.
103    provenance: AuthoredProvenanceState = ""
104    #: What a port was ported from; only a ``port`` carries one.
105    source_url: str = ""
106    #: ``yes`` / ``no``, or ``""`` — **not known**, never read as ``no``.
107    ai_assisted: AuthoredAiAssistanceState = ""
108    #: Whether the platform may use it to evaluate AI agents.
109    eval_use: bool = False
110    #: How the author is credited in public.
111    credit: AuthoredCredit = "anonymous"
112    #: The agreement version accepted at upload, stamped from the
113    #: competition; ``""`` when the event asked for none.
114    terms_version: str = ""
115    terms_accepted_at: AwareDatetime | None = None
116    #: Where it goes once published (plan P3.4): ``public``, the catalog
117    #: every competition may curate, or ``eval_only`` — the evaluation
118    #: pool, kept out of every public listing and launched only through a
119    #: competition that is not public and curates it. Only an original
120    #: upload whose author granted evaluation use may choose the pool.
121    destination: AuthoredDestination = "public"

What an author declared about an upload under the contributor agreement, and which agreement they accepted (docs/community-authoring-plan.md P1.3).

The base of every row model that describes an upload, so a field added here reaches the author's listing and the operator's queue alike — the composers carry by field-set intersection.

⚠️ "" on license, provenance and ai_assisted is nobody said — an upload from before the agreement existed, or to an event that asks for none — and a reader takes the strictest meaning: platform only, not known. It is never shown as an answer. The two grants default to their refusal instead: no evaluation use, anonymous credit.

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'
terms_version: str = ''
terms_accepted_at: pydantic.types.AwareDatetime | None = None
destination: Literal['public', 'eval_only'] = 'public'
class AuthoredGraduationInfo(ctfy.core.models.CtfyModel):
765class AuthoredGraduationInfo(CtfyModel):
766    """Whether an upload may graduate into the challenge corpus, and as what.
767
768    ``docs/community-authoring-plan.md`` P3.3. The platform opens nothing:
769    this is the answer a platform admin reads before downloading the bundle
770    (``GET .../graduation/bundle``) that a person carries to the corpus
771    repository as a pull request, whose own review is the gate.
772    """
773
774    challenge_id: str
775    #: Every reason it may not graduate yet, collected — empty when it may.
776    #: A code a client renders in its reader's language, with the English
777    #: beside it for one that has no copy for the code.
778    refusals: list[AuthoringRefusal] = Field(default_factory=list)
779    #: The corpus id it would take: the one asked for, or else the next
780    #: ``COMMUNITY-NNN`` this deployment's catalog does not hold.
781    corpus_id: str
782    #: The licence its author declared, which the bundle's ``LICENSE``
783    #: carries. ``""`` is nothing declared, which refuses.
784    license: AuthoredLicenseState = ""
785    #: The words for the pull request a person opens with the bundle.
786    pull_request_title: str
787    pull_request_body: str

Whether an upload may graduate into the challenge corpus, and as what.

docs/community-authoring-plan.md P3.3. The platform opens nothing: this is the answer a platform admin reads before downloading the bundle (GET .../graduation/bundle) that a person carries to the corpus repository as a pull request, whose own review is the gate.

challenge_id: str = PydanticUndefined
refusals: list[ctfy.core.authoring_refusals.AuthoringRefusal] = PydanticUndefined
corpus_id: str = PydanticUndefined
license: Union[Literal[''], Literal['CC-BY-4.0', 'CC-BY-NC-4.0', 'CC-BY-SA-4.0', 'MIT', 'Apache-2.0', 'platform-only']] = ''
pull_request_title: str = PydanticUndefined
pull_request_body: str = PydanticUndefined
class AuthoredQualityFlag(ctfy.core.models.CtfyModel):
55class AuthoredQualityFlag(CtfyModel):
56    """Players' reports on an upload that reached the threshold (plan P2.6).
57
58    One per report kind — ``buggy``, ``infra_broken`` or ``guessy`` — with
59    how many different people gave it, counted against the current
60    version of the tree.
61    """
62
63    reaction: Reaction
64    count: int

Players' reports on an upload that reached the threshold (plan P2.6).

One per report kind — buggy, infra_broken or guessy — with how many different people gave it, counted against the current version of the tree.

reaction: Literal['addictive', 'mindblown', 'learned', 'goat_setter', 'overthought', 'guessy', 'brutal', 'buggy', 'infra_broken'] = PydanticUndefined
count: int = PydanticUndefined
class AuthoredReceptionRow(ctfy.core.models.CtfyModel):
894class AuthoredReceptionRow(CtfyModel):
895    """How the field fared against one challenge its author wrote.
896
897    ⚠️ **The counts are the rate's own inputs, not a reading of it.**
898    `PeerAuthoringContext` carries ``solved`` and ``population`` beside
899    ``rates`` precisely so this row does not have to recover a headcount
900    from a float — ``round(rate * field_size)`` is a second spelling of
901    the division that produced it, and the two disagree the moment
902    either rounds.
903    """
904
905    challenge_id: str
906    #: The catalog's name for it, or ``""`` when the spec has gone —
907    #: never the id twice, which reads as a name.
908    name: str = ""
909    state: AuthoredReceptionState = "not_curated"
910    #: How many entrants of the author's own division fully solved it.
911    solvers: int = 0
912    #: The denominator: that division's size minus the author, who
913    #: cannot solve their own upload. ``0`` when there is no rate.
914    field_size: int = 0
915    #: ``solvers / field_size``, or ``None`` when ``state`` is not
916    #: ``priced``. ⚠️ Never ``0.0`` for an unpriced row: zero is the
917    #: floor of the curve and a real verdict ("nobody could solve it"),
918    #: which is exactly the sentence an uncurated challenge has not
919    #: earned.
920    solve_rate: float | None = None
921    #: What the curve pays for that rate, in ``[floor, 1]`` — the
922    #: multiplier the weight is applied to. ``None`` with ``solve_rate``.
923    multiplier: float | None = None

How the field fared against one challenge its author wrote.

⚠️ The counts are the rate's own inputs, not a reading of it. PeerAuthoringContext carries solved and population beside rates precisely so this row does not have to recover a headcount from a float — round(rate * field_size) is a second spelling of the division that produced it, and the two disagree the moment either rounds.

challenge_id: str = PydanticUndefined
name: str = ''
state: Literal['priced', 'not_curated', 'no_field', 'auto_bucket'] = 'not_curated'
solvers: int = 0
field_size: int = 0
solve_rate: float | None = None
multiplier: float | None = None
AuthoredReceptionState = typing.Literal['priced', 'not_curated', 'no_field', 'auto_bucket']
class AuthoredRevisionDiff(ctfy.core.models.CtfyModel):
740class AuthoredRevisionDiff(CtfyModel):
741    """A proposed revision against the live tree — what a reviewer reads.
742
743    ⚠️ **The diff, because the reviewer has read the live version.** A
744    second review costs the size of the change only if the change is what
745    they are shown; unchanged files are counted, not listed, and remain
746    readable through the upload's own file route.
747    """
748
749    revision: AdminAuthoredRevisionRow
750    changes: list[AuthoredRevisionFileChange] = Field(default_factory=list)
751    unchanged: int = 0
752    #: Question ids the live tree serves and the revision would not. Always
753    #: empty for a stored proposal — the door refuses one that drops any —
754    #: and asked again at apply.
755    dropped_questions: list[str] = Field(default_factory=list)
756    #: The revision's whole tree, with where each file runs (plan P2.3).
757    files: list[AuthoredChallengeFileEntry] = Field(default_factory=list)
758    #: Whether applying asks the platform admin to confirm they read the
759    #: files marked ``executes``: the upload is published and the revision
760    #: has an execution surface. An unpublished upload's review happens at
761    #: its publish instead.
762    needs_exec_review: bool = False

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

⚠️ The diff, because the reviewer has read the live version. A second review costs the size of the change only if the change is what they are shown; unchanged files are counted, not listed, and remain readable through the upload's own file route.

revision: AdminAuthoredRevisionRow = PydanticUndefined
changes: list[AuthoredRevisionFileChange] = PydanticUndefined
unchanged: int = 0
dropped_questions: list[str] = PydanticUndefined
files: list[AuthoredChallengeFileEntry] = PydanticUndefined
needs_exec_review: bool = False
class AuthoredRevisionFileChange(ctfy.core.models.CtfyModel):
723class AuthoredRevisionFileChange(CtfyModel):
724    """One path a revision changes (plan P3.1, `ctfy.core.authored_revisions`)."""
725
726    path: str
727    change: FileChangeKind
728    #: Decoded sizes; ``0`` on the side where the file does not exist.
729    size_before: int = 0
730    size_after: int = 0
731    #: A unified diff from the live file to the revision's, or ``""`` when
732    #: ``omitted`` says why not.
733    diff: str = ""
734    #: ``binary``, ``too_large`` or ``over_budget`` — ⚠️ never ``""`` beside
735    #: an empty diff of a changed file, which would read as *nothing to
736    #: see* about a file the reviewer was simply not shown.
737    omitted: DiffOmission = ""

One path a revision changes (plan P3.1, ctfy.core.authored_revisions).

path: str = PydanticUndefined
change: Literal['added', 'removed', 'modified'] = PydanticUndefined
size_before: int = 0
size_after: int = 0
diff: str = ''
omitted: Literal['', 'binary', 'too_large', 'over_budget'] = ''
class AuthoredRevisionInfo(ctfy.server.models.AuthoredRevisionRow):
159class AuthoredRevisionInfo(AuthoredRevisionRow):
160    """A revision plus the paths in it — what proposing one answers with,
161    where the tree is already in hand (`AuthoredChallengeInfo`'s rule)."""
162
163    files: list[str] = Field(default_factory=list)

A revision plus the paths in it — what proposing one answers with, where the tree is already in hand (AuthoredChallengeInfo's rule).

files: list[str] = PydanticUndefined
class AuthoredRevisionRow(ctfy.core.models.CtfyModel):
124class AuthoredRevisionRow(CtfyModel):
125    """A revision of a reviewed upload, as its author reads it (plan P3.1).
126
127    ``docs/community-authoring-plan.md`` P3.1: one of the upload's recorded
128    authors proposes a new tree for the same id; a reviewer rules on it as
129    a diff against the live version; a platform admin applies it.
130
131    ⚠️ **No operator ids**, `AuthoredChallengeRow`'s rule: who ruled is the
132    admin queue's business, and the author reads the verdict and the note.
133    """
134
135    id: str
136    #: The upload it revises.
137    authored_challenge_id: str
138    #: Who proposed it — one of the upload's recorded authors.
139    user_id: str
140    #: What to call them, composed server-side for the audience reading it
141    #: (`AuthoredChallengeRow.author_display_name`'s rule); ``""`` is *no
142    #: name to show*.
143    author_display_name: str = ""
144    #: The revision's own ``metadata.yaml`` ``name:``; the upload takes it
145    #: when the revision is applied.
146    title: str = ""
147    submitted_at: AwareDatetime | None = None
148    file_count: int = 0
149    total_bytes: int = 0
150    review_status: ReviewStatus = "pending"
151    reviewed_at: AwareDatetime | None = None
152    #: Why it was turned down, or what the reviewer said approving it.
153    review_note: str = ""
154    #: When a platform admin put it live. ``None`` is *a proposal* — the
155    #: upload still serves its own tree.
156    applied_at: AwareDatetime | None = None

A revision of a reviewed upload, as its author reads it (plan P3.1).

docs/community-authoring-plan.md P3.1: one of the upload's recorded authors proposes a new tree for the same id; a reviewer rules on it as a diff against the live version; a platform admin applies it.

⚠️ No operator ids, AuthoredChallengeRow's rule: who ruled is the admin queue's business, and the author reads the verdict and the note.

id: str = PydanticUndefined
authored_challenge_id: str = PydanticUndefined
user_id: str = PydanticUndefined
author_display_name: str = ''
title: str = ''
submitted_at: pydantic.types.AwareDatetime | None = None
file_count: int = 0
total_bytes: int = 0
review_status: Literal['pending', 'approved', 'rejected'] = 'pending'
reviewed_at: pydantic.types.AwareDatetime | None = None
review_note: str = ''
applied_at: pydantic.types.AwareDatetime | None = None
AuthoringDivision = typing.Union[typing.Literal[''], typing.Literal['human', 'agent']]
class AuthoringReceptionInfo(ctfy.core.models.CtfyModel):
926class AuthoringReceptionInfo(CtfyModel):
927    """What this caller's team earned for the challenges it wrote.
928
929    ⚠️ **Composed from the board's own producer**, never from a second
930    tally: the rows come out of `PeerAuthoringRule.prepare` run over the
931    facts `board_facts` scopes, and ``authoring_score`` out of the same
932    rule's ``score``. A page that summed the multipliers itself would be
933    a second implementation of the rounding, free to disagree with the
934    scoreboard on the next tab.
935    """
936
937    #: Whether this competition scores authoring at all. ``False`` on
938    #: every other rule, and the rows are then empty.
939    scored: bool = False
940    #: The division the rates above are against — a rate is per
941    #: division, so the number is unreadable without it.
942    division: AuthoringDivision = ""
943    #: The competition's stored multiplier. ``0`` is the default and
944    #: means authoring currently pays nothing, which is a real state
945    #: rather than a missing one.
946    authoring_weight: float = 0.0
947    #: What the board credits this team for authoring, right now.
948    #:
949    #: ⚠️ Recomputed here rather than read off the cached board, so this
950    #: page can be a moment fresher than the scoreboard's TTL. The two
951    #: cannot *disagree* — same rule, same facts — they can only be at
952    #: different instants, which every live number on the platform is.
953    authoring_score: int = 0
954    rows: list[AuthoredReceptionRow] = Field(default_factory=list)

What this caller's team earned for the challenges it wrote.

⚠️ Composed from the board's own producer, never from a second tally: the rows come out of PeerAuthoringRule.prepare run over the facts board_facts scopes, and authoring_score out of the same rule's score. A page that summed the multipliers itself would be a second implementation of the rounding, free to disagree with the scoreboard on the next tab.

scored: bool = False
division: Union[Literal[''], Literal['human', 'agent']] = ''
authoring_weight: float = 0.0
authoring_score: int = 0
rows: list[AuthoredReceptionRow] = PydanticUndefined
class AuthoringReviewerInfo(ctfy.core.models.CtfyModel):
1339class AuthoringReviewerInfo(CtfyModel):
1340    """One per-competition authoring-reviewer grant, for the super-admin
1341    card on the competition detail page.
1342
1343    ``docs/community-authoring-plan.md`` P2.3, decision 16: the holder
1344    rules on the content of this event's uploaded challenges and reaches
1345    nothing else — not the organiser's console, not the entrants'
1346    registration details. Its own row type rather than a third
1347    ``CompetitionAdminInfo.role``, because every reader of that table
1348    decides something this grant must not reach.
1349    """
1350
1351    user_id: str
1352    display_name: str = ""
1353    email: str = ""
1354    granted_by: str = ""
1355    #: What the super-admin who granted it is called — a user id on the
1356    #: wire carries its label beside it.
1357    granted_by_display_name: str = ""
1358    granted_at: datetime | None = None

One per-competition authoring-reviewer grant, for the super-admin card on the competition detail page.

docs/community-authoring-plan.md P2.3, decision 16: the holder rules on the content of this event's uploaded challenges and reaches nothing else — not the organiser's console, not the entrants' registration details. Its own row type rather than a third CompetitionAdminInfo.role, because every reader of that table decides something this grant must not reach.

user_id: str = PydanticUndefined
display_name: str = ''
email: str = ''
granted_by: str = ''
granted_by_display_name: str = ''
granted_at: datetime.datetime | None = None
class AuthoringScaleInfo(ctfy.core.models.CtfyModel):
356class AuthoringScaleInfo(CtfyModel):
357    """What a peer-authoring board's ``authoring_weight`` is worth.
358
359    ⚠️ **The reading is "how many solved challenges", not "what share of
360    the total", and the missing denominator is why.** A share needs to
361    know how many challenges a team writes and how many it solves; the
362    platform holds neither — ``max_pending_authored_challenges`` bounds a
363    review queue, not an event — so a percentage here could only come
364    from an assumption, and an assumption rendered as a figure is read as
365    a measurement.
366
367    ⚠️ **Composed here because the price is.** ``scoring_params`` crosses
368    as ``dict[str, Any]``, so a browser-side version would be a second
369    implementation of the point table and its decay — see
370    ``ctfy.server.authoring_scale``.
371    """
372
373    #: Whether this competition scores authoring at all — the producer's
374    #: own ``isinstance`` verdict on the **stored** rule, which it used
375    #: to compute and drop.
376    #:
377    #: ⚠️ **Every other field below is a default on a ``False``, and the
378    #: defaults are indistinguishable from real answers.** A rule that is
379    #: not ``peer_authoring`` returns a bare row, so ``challenges_priced``
380    #: reads ``0`` and ``equivalent_challenges`` reads ``None`` however
381    #: full the challenge set is — measured across four rules against a
382    #: set with a priceable challenge in it. A reader that branches on
383    #: those without asking this one first says *nothing in this event's
384    #: challenge set can be priced yet* about a set that is priced and
385    #: curated, so this is asked **before** them, never instead of them.
386    #:
387    #: ⚠️ ``False`` by default, `AuthoringReceptionInfo.scored`'s rule on
388    #: the same question: a producer that forgets it takes a real
389    #: reading away, which somebody reports, where the other direction
390    #: puts the false sentence back in silence.
391    scored: bool = False
392    #: The stored weight, echoed so a reader knows which number the rest
393    #: of this row is about. Zero for a competition that has not saved
394    #: the rule, which is the honest answer rather than a second state:
395    #: authoring scores nothing there.
396    weight: float = 0.0
397    #: How many of the competition's challenges could be priced.
398    challenges_priced: int = 0
399    #: The typical challenge's full-solve price. Shipped beside the ratio
400    #: because it is what makes the ratio checkable against the
401    #: organiser's own point table.
402    median_challenge_points: int = 0
403    #: ⚠️ ``None``, never ``0.0``, for a set nothing could be priced
404    #: from. Zero is a statement about the *weight* ("authoring is worth
405    #: nothing here"); an empty challenge set says nothing about the
406    #: weight at all, and the two get opposite advice.
407    equivalent_challenges: float | None = None
408    #: Whether the solving half decays. When it does, the solving side
409    #: only shrinks as the field grows, so the ratio is a **floor** — the
410    #: direction that matters, since understating it is what makes an
411    #: organiser pick a weight larger than they meant.
412    decays: bool = False

What a peer-authoring board's authoring_weight is worth.

⚠️ The reading is "how many solved challenges", not "what share of the total", and the missing denominator is why. A share needs to know how many challenges a team writes and how many it solves; the platform holds neither — max_pending_authored_challenges bounds a review queue, not an event — so a percentage here could only come from an assumption, and an assumption rendered as a figure is read as a measurement.

⚠️ Composed here because the price is. scoring_params crosses as dict[str, Any], so a browser-side version would be a second implementation of the point table and its decay — see ctfy.server.authoring_scale.

scored: bool = False
weight: float = 0.0
challenges_priced: int = 0
median_challenge_points: int = 0
equivalent_challenges: float | None = None
decays: bool = False
class AuthoringTemplateFile(ctfy.core.models.CtfyModel):
462class AuthoringTemplateFile(CtfyModel):
463    """One file in a starter template, as the download would write it."""
464
465    #: Relative to the benchmark directory, so it reads the way the
466    #: spec does (``services/app/main.go``) rather than carrying the
467    #: archive's wrapping directory, which is an artifact of the
468    #: transport and not something the author will ever see on disk.
469    path: str
470    size_bytes: int = 0

One file in a starter template, as the download would write it.

path: str = PydanticUndefined
size_bytes: int = 0
class AuthoringTemplateManifest(ctfy.core.models.CtfyModel):
473class AuthoringTemplateManifest(CtfyModel):
474    """What ``GET /authoring/template`` would hand over, without handing it.
475
476    ⚠️ **The page downloaded a ``.tar.gz`` blind.** A picker with nine
477    shapes behind it and no way to see inside one is a choice made from
478    a label; the listing is what turns it into a choice made from the
479    thing.
480
481    ⚠️ **Derived from the same `scaffold_authored`, never described.**
482    A hand-written listing beside a live scaffolder is the second
483    specification this feature already refused once — the reason the
484    download route *builds* the tree per request rather than serving a
485    checked-in archive — and it would go stale on whichever path nobody
486    exercised. So this route builds the tree too and reads it back;
487    `TestTheManifestIsTheArchive` drives both and compares the sets,
488    because two producers of one fact is the whole hazard.
489
490    ⚠️ ``total_bytes`` is the **decoded** sum rather than the archive's
491    size, so it is the same quantity `AuthoredChallengeInfo.total_bytes`
492    reports on the upload that comes back. Two numbers for one fact is
493    the recurring cost here; the one an author can compare is worth
494    more than the one that measures the transfer.
495    """
496
497    files: list[AuthoringTemplateFile] = Field(default_factory=list)
498    total_bytes: int = 0

What GET /authoring/template would hand over, without handing it.

⚠️ The page downloaded a .tar.gz blind. A picker with nine shapes behind it and no way to see inside one is a choice made from a label; the listing is what turns it into a choice made from the thing.

⚠️ Derived from the same scaffold_authored, never described. A hand-written listing beside a live scaffolder is the second specification this feature already refused once — the reason the download route builds the tree per request rather than serving a checked-in archive — and it would go stale on whichever path nobody exercised. So this route builds the tree too and reads it back; TestTheManifestIsTheArchive drives both and compares the sets, because two producers of one fact is the whole hazard.

⚠️ total_bytes is the decoded sum rather than the archive's size, so it is the same quantity AuthoredChallengeInfo.total_bytes reports on the upload that comes back. Two numbers for one fact is the recurring cost here; the one an author can compare is worth more than the one that measures the transfer.

files: list[AuthoringTemplateFile] = PydanticUndefined
total_bytes: int = 0
class AwdArenaGate(ctfy.core.models.CtfyModel):
392class AwdArenaGate(CtfyModel):
393    """Whether this arena was measured whole before kickoff (#1713).
394
395    ⚠️ **Read-only, and not configuration**: nothing an organiser saves
396    on the match form reaches it, and `AwdMatchConfig` carries none of
397    it — a full-replace ``PUT`` carries it forward untouched. The check
398    writes the verdict; ``POST .../awd/kickoff-override`` writes the
399    override, as an audit fact with a name on it.
400    """
401
402    #: ``None`` = never checked — a match that predates the check, which
403    #: the gate deliberately does not hold.
404    verdict: ArenaCheckVerdict | None = None
405    checked_at: AwareDatetime | None = None
406    #: Every link that did not answer ``ok``, source-then-target.
407    links: list[AwdArenaGateLink] = Field(default_factory=list)
408    #: Whether the tick is holding this match's first round right now.
409    #: ⚠️ **Composed server-side** (`ctfy.core.awd_arena_check.kickoff_held`)
410    #: so the page cannot hold a second copy of the rule — the browser
411    #: that re-derived it would disagree with the tick on the one question
412    #: that decides whether a round runs.
413    held: bool = False
414    #: Who pressed "start anyway". A user id; ``""`` = nobody.
415    override_by: str = ""
416    #: What to call them — `operator_label`. ⚠️ ``""`` when the account
417    #: is gone, never the id (which is beside it).
418    override_by_display_name: str = ""
419    override_at: AwareDatetime | None = None

Whether this arena was measured whole before kickoff (#1713).

⚠️ Read-only, and not configuration: nothing an organiser saves on the match form reaches it, and AwdMatchConfig carries none of it — a full-replace PUT carries it forward untouched. The check writes the verdict; POST .../awd/kickoff-override writes the override, as an audit fact with a name on it.

verdict: ctfy.core.enums.ArenaCheckVerdict | None = None
checked_at: pydantic.types.AwareDatetime | None = None
held: bool = False
override_by: str = ''
override_by_display_name: str = ''
override_at: pydantic.types.AwareDatetime | None = None
class AwdArenaPeerRow(ctfy.core.models.CtfyModel):
712class AwdArenaPeerRow(CtfyModel):
713    """One person's tunnel, as an organiser needs to see it.
714
715    ⚠️ **No ``config`` and no ``public_key``.** An organiser needs to know
716    *that* somebody is connected and where, not to hold their tunnel —
717    and since the private half is stored nowhere, a config rendered here
718    could only be one that does not work. A credential on a page nobody
719    asked to be handed one is a liability with no reader.
720
721    ⚠️ **An organiser sees the real name.** ``/admin`` paths are outside
722    the anonymity projection by design (``test_anonymity_doors`` says so
723    in as many words): the organiser is precisely who it is not against,
724    and an operator who cannot join a withheld entrant to an address
725    cannot repair their tunnel.
726    """
727
728    user_id: str = ""
729    #: ⚠️ Falls back to the id rather than to ``""`` — a peer whose
730    #: account has gone is still a peer on a worker's device, and a blank
731    #: row is the one entry an operator is looking for rendered as
732    #: nothing at all.
733    display_name: str = ""
734    #: The worker its tunnel terminates on.
735    node_id: str = ""
736    address: str = ""
737    joined_at: AwareDatetime | None = None
738    #: What the last round's probe found, or ``None`` when none has
739    #: asked. See :class:`AwdArenaLink` — absent is not down.
740    link: AwdArenaLink | None = None

One person's tunnel, as an organiser needs to see it.

⚠️ No config and no public_key. An organiser needs to know that somebody is connected and where, not to hold their tunnel — and since the private half is stored nowhere, a config rendered here could only be one that does not work. A credential on a page nobody asked to be handed one is a liability with no reader.

⚠️ An organiser sees the real name. /admin paths are outside the anonymity projection by design (test_anonymity_doors says so in as many words): the organiser is precisely who it is not against, and an operator who cannot join a withheld entrant to an address cannot repair their tunnel.

user_id: str = ''
display_name: str = ''
node_id: str = ''
address: str = ''
joined_at: pydantic.types.AwareDatetime | None = None
class AwdArenaPeersResponse(ctfy.core.models.CtfyModel):
743class AwdArenaPeersResponse(CtfyModel):
744    """Everybody currently on one match's arena.
745
746    ⚠️ Ordered by **address**, which is stable and is also the column an
747    operator is cross-referencing against a packet capture. Ordering by
748    name would reshuffle the table when somebody edits their profile.
749    """
750
751    competition_id: str = ""
752    peers: list[AwdArenaPeerRow] = Field(default_factory=list)

Everybody currently on one match's arena.

⚠️ Ordered by address, which is stable and is also the column an operator is cross-referencing against a packet capture. Ordering by name would reshuffle the table when somebody edits their profile.

competition_id: str = ''
peers: list[AwdArenaPeerRow] = PydanticUndefined
class AwdAttackAnchorResponse(ctfy.core.models.CtfyModel):
1039class AwdAttackAnchorResponse(CtfyModel):
1040    """The match's absolute attack scale, one row per entrant.
1041
1042    ⚠️ **The rows are the roster, not the recorded rows** — an entrant
1043    who never touched the free box has no capture at all, and deriving
1044    the listing from the rows would drop exactly the entrant whose
1045    absence is the finding.
1046    """
1047
1048    competition_id: str = ""
1049    #: Rounds that have finished, from the match clock. ⚠️ Published
1050    #: beside the rows because `offered` alone cannot say whether a low
1051    #: number is a young match or a free box that spent it down — and
1052    #: the gap between the two *is* the instrument's own health.
1053    rounds_finished: int = 0
1054    #: Whether this match runs a reference victim at all. ⚠️ An unarmed
1055    #: match answers 200 with every row at zero and this `False`, never
1056    #: 404: "there is no free box" and "nobody broke the free box" are
1057    #: opposite facts that an empty table cannot tell apart. ⚠️ Keyed on
1058    #: `reference_victim`, never `platform_attacker` — the two anchors
1059    #: are armed by two independent switches, and reading the wrong one
1060    #: reports a scale that is not running as one that found nothing.
1061    armed: bool = False
1062    rows: list[AwdAttackAnchorRow] = Field(default_factory=list)

The match's absolute attack scale, one row per entrant.

⚠️ The rows are the roster, not the recorded rows — an entrant who never touched the free box has no capture at all, and deriving the listing from the rows would drop exactly the entrant whose absence is the finding.

competition_id: str = ''
rounds_finished: int = 0
armed: bool = False
rows: list[AwdAttackAnchorRow] = PydanticUndefined
class AwdAttackAnchorRow(ctfy.core.models.CtfyModel):
945class AwdAttackAnchorRow(CtfyModel):
946    """One entrant's standing against the reference victim (§16.6).
947
948    The mirror of `AwdDefenceAnchorRow`: that one fixes the *attacker*
949    every entrant meets, this one fixes the *victim*. One unpatched box
950    that nobody owns and nobody patches sits in the arena, so "broke a
951    known-vulnerable target in N of the M rounds it was up" is a number
952    with the same meaning in every match — where a place on the relative
953    board is a statement about the field they happened to draw.
954    """
955
956    team_id: str = ""
957    #: ⚠️ **The organiser's own read, so the real name.** `/admin`-scoped
958    #: exactly as its defence twin; publishing either on a *player*
959    #: surface is a separate decision needing `ctfy.server.anonymity`.
960    team_name: str = ""
961    #: Rounds the free box was demonstrably attackable — the
962    #: denominator. ⚠️ **Match-wide**: it is a fact about the box, so
963    #: every row of one response carries the same number, and that
964    #: identity is what lets two entrants be compared at all. A
965    #: per-entrant denominator would put "3 of 10" beside "3 of 11" in
966    #: one match, which is the relative measurement this replaces.
967    offered: int = 0
968    #: Offered rounds in which this entrant took at least one of its
969    #: flags. Per **round**, never per service.
970    breached: int = 0
971    #: The earliest round they broke it. ⚠️ `None`, never `-1` and never
972    #: `0` — round 0 is a real round.
973    first_breach_tick: int | None = None
974
975    @computed_field
976    @property
977    def breach_rate(self) -> float | None:
978        """Share of offered rounds broken, or `None` for none offered.
979
980        ⚠️ **`None`, never `0.0`.** A zero asserts this entrant failed
981        every round they were given, which is the opposite of what an
982        entrant the platform never offered a working target deserves.
983        ⚠️ A `computed_field` rather than a plain property because this
984        is a *wire* model: the client holds both halves and could
985        divide, and then two readers disagree about the rounding of the
986        number the whole scale is — `hold_rate`'s reason above.
987        """
988        if self.offered <= 0:
989            return None
990        return self.breached / self.offered

One entrant's standing against the reference victim (§16.6).

The mirror of AwdDefenceAnchorRow: that one fixes the attacker every entrant meets, this one fixes the victim. One unpatched box that nobody owns and nobody patches sits in the arena, so "broke a known-vulnerable target in N of the M rounds it was up" is a number with the same meaning in every match — where a place on the relative board is a statement about the field they happened to draw.

team_id: str = ''
team_name: str = ''
offered: int = 0
breached: int = 0
first_breach_tick: int | None = None
breach_rate: float | None
975    @computed_field
976    @property
977    def breach_rate(self) -> float | None:
978        """Share of offered rounds broken, or `None` for none offered.
979
980        ⚠️ **`None`, never `0.0`.** A zero asserts this entrant failed
981        every round they were given, which is the opposite of what an
982        entrant the platform never offered a working target deserves.
983        ⚠️ A `computed_field` rather than a plain property because this
984        is a *wire* model: the client holds both halves and could
985        divide, and then two readers disagree about the rounding of the
986        number the whole scale is — `hold_rate`'s reason above.
987        """
988        if self.offered <= 0:
989            return None
990        return self.breached / self.offered

Share of offered rounds broken, or None for none offered.

⚠️ None, never 0.0. A zero asserts this entrant failed every round they were given, which is the opposite of what an entrant the platform never offered a working target deserves. ⚠️ A computed_field rather than a plain property because this is a wire model: the client holds both halves and could divide, and then two readers disagree about the rounding of the number the whole scale is — hold_rate's reason above.

class AwdAttackMatrixResponse(ctfy.core.models.CtfyModel):
1168class AwdAttackMatrixResponse(CtfyModel):
1169    """Who took what from whom, cumulative through one settled round.
1170
1171    ⚠️ **``team_ids`` is both axes, published once.** Rows and columns
1172    are the same population by construction; sending it twice, or letting
1173    a client derive the columns from the rows it was given, is how the
1174    two come to disagree after a projection has dropped a row.
1175
1176    ⚠️ **The roster is the axis, never the captures** — an entrant who
1177    has taken nothing and lost nothing is a row of zeros and it is the
1178    most informative row on the grid. Same rule the defence matrix states
1179    as "configuration, not data".
1180    """
1181
1182    #: The last round folded in; ``-1`` when nothing has. In single-round
1183    #: mode (``at_tick`` set) it equals ``at_tick`` — the only round drawn.
1184    through_tick: int = -1
1185    #: The single round this matrix isolates, or ``None`` for the
1186    #: cumulative fold. ⚠️ **A distinct field, never inferred from
1187    #: ``through_tick``**: a per-round slider must be able to tell a
1188    #: one-round fold from a cumulative one that stops at the same round,
1189    #: which the two would otherwise share. ``None`` is today's cumulative
1190    #: answer — an older client that ignores this field reads the
1191    #: cumulative matrix exactly as before.
1192    at_tick: int | None = None
1193    team_ids: list[str] = Field(default_factory=list)
1194    rows: list[AwdAttackMatrixRow] = Field(default_factory=list)

Who took what from whom, cumulative through one settled round.

⚠️ team_ids is both axes, published once. Rows and columns are the same population by construction; sending it twice, or letting a client derive the columns from the rows it was given, is how the two come to disagree after a projection has dropped a row.

⚠️ The roster is the axis, never the captures — an entrant who has taken nothing and lost nothing is a row of zeros and it is the most informative row on the grid. Same rule the defence matrix states as "configuration, not data".

through_tick: int = -1
at_tick: int | None = None
team_ids: list[str] = PydanticUndefined
rows: list[AwdAttackMatrixRow] = PydanticUndefined
class AwdAttackMatrixRow(ctfy.core.models.CtfyModel):
1137class AwdAttackMatrixRow(CtfyModel):
1138    """One entrant's attacking record against the whole field."""
1139
1140    team_id: str = ""
1141    team_name: str = ""
1142    #: ⚠️ A wire model carrying a team identity needs a **stated fate**,
1143    #: not a reader — `AwdBoardEntry` and `VirtualGhostRow` shipped
1144    #: without one and both boards rendered the raw tag.
1145    #: `test_anonymity_readers.py` pins the producer set for that reason,
1146    #: and here the fate is stronger than usual: this model names a team
1147    #: on *both* axes, so a projection that relabelled only the row would
1148    #: be undone by reading down the column.
1149    anonymous: bool = False
1150    #: ``{victim_team_id: captures}``. A victim this team never took is
1151    #: **absent**, never a stored zero — the grid is filled from
1152    #: ``team_ids``, so the wire carries only what happened.
1153    captures: dict[str, int] = Field(default_factory=dict)
1154    #: Services taken from other entrants, through the settled round.
1155    taken: int = 0
1156    #: Services of this entrant's own taken by anybody.
1157    #:
1158    #: ⚠️ **Composed server-side over the *whole* matrix, before any
1159    #: anonymity projection**, and that is the one number here a client
1160    #: must not re-derive: summing the drawn column gives a smaller
1161    #: answer once a `hidden` entrant's row has been removed, and an
1162    #: anonymous entrant *did* enter and must keep counting
1163    #: (`server/participation.py`'s own distinction). So a row can
1164    #: legitimately show `lost` above what its visible column sums to.
1165    lost: int = 0

One entrant's attacking record against the whole field.

team_id: str = ''
team_name: str = ''
anonymous: bool = False
captures: dict[str, int] = PydanticUndefined
taken: int = 0
lost: int = 0
class AwdBoardEntry(ctfy.core.models.CtfyModel):
113class AwdBoardEntry(CtfyModel):
114    """One team's standing after one settled round.
115
116    The three components are their own columns rather than a single
117    total (§5.5): a team needs to know whether it is losing on attack or
118    bleeding on defence, and one number cannot say.
119    """
120
121    rank: int = 0
122    team_id: str = ""
123    #: ⚠️ The one field `AwdBoardRow` cannot supply, and the reason this
124    #: wire model exists at all — a board of uuids is not a board.
125    team_name: str = ""
126    total: float = 0.0
127    attack: float = 0.0
128    defence: float = 0.0
129    sla: float = 0.0
130    #: service id → SLA status, this round. ⚠️ A service the checker
131    #: never reached is **absent**, not `up`: a missing verdict means
132    #: "not probed", and rendering it as healthy would let a round the
133    #: checker never got to look like a clean sheet.
134    services: dict[str, SlaStatus] = Field(default_factory=dict)
135    #: Whose agent this row is, when it is somebody's. ⚠️ **The one
136    #: board where this was missing, on the format whose whole reason
137    #: for existing is model-vs-model** (§16): `ScoreboardEntry` and
138    #: `HarnessStanding` have carried it since the eval work and this
139    #: one never got it, so an AWD board could not say which agent a
140    #: row was, could not link to its author, and did not publish the
141    #: digest that makes the result reproducible.
142    #:
143    #: ⚠️ `None` for a person **and** for the platform's own eval
144    #: entrant, which has no owner — see :class:`AgentEntrantInfo`. The
145    #: absence is silence, never a claim.
146    #:
147    #: ⚠️ **Attached after the board is computed**, never carried
148    #: through the scoring layer: `core/awd_scoring.py` is pure and
149    #: reads only the facts it names, which is what makes "no rule ranks
150    #: on who built the agent" structural rather than a convention.
151    #:
152    #: ⚠️ **Cleared by `anonymise_awd_board` for a withheld row.** It
153    #: carries the owner's id, their display name and the repository
154    #: they published — `_board_fields`' docstring gives the reason for
155    #: its twin, and that anonymiser writes its own update dict, so this
156    #: field does not fall out for free.
157    agent: AgentEntrantInfo | None = None
158    #: See :attr:`ctfy.server.models.ScoreboardEntry.anonymous`.
159    #:
160    #: ⚠️ ``services`` stays, and it names no one: an AWD board that
161    #: withheld which services are up would stop being the thing the
162    #: whole format is watched for. A determined observer can correlate
163    #: an SLA pattern with a box they can reach — see the note in
164    #: ``docs/notes/awd.md``; the arena network is the boundary there,
165    #: not the board.
166    anonymous: bool = False

One team's standing after one settled round.

The three components are their own columns rather than a single total (§5.5): a team needs to know whether it is losing on attack or bleeding on defence, and one number cannot say.

rank: int = 0
team_id: str = ''
team_name: str = ''
total: float = 0.0
attack: float = 0.0
defence: float = 0.0
sla: float = 0.0
services: dict[str, ctfy.core.awd.SlaStatus] = PydanticUndefined
agent: AgentEntrantInfo | None = None
anonymous: bool = False
class AwdBoardResponse(ctfy.core.models.CtfyModel):
169class AwdBoardResponse(CtfyModel):
170    """One round's standings.
171
172    ``tick`` is the round actually rendered, which is the **last settled**
173    one unless the caller named another — never the round in progress,
174    which by construction has no scores yet.
175    """
176
177    tick: int = -1
178    rows: list[AwdBoardEntry] = Field(default_factory=list)

One round's standings.

tick is the round actually rendered, which is the last settled one unless the caller named another — never the round in progress, which by construction has no scores yet.

tick: int = -1
rows: list[AwdBoardEntry] = PydanticUndefined
class AwdCaptureCorroborationRow(ctfy.core.models.CtfyModel):
755class AwdCaptureCorroborationRow(CtfyModel):
756    """One recorded capture, and what the victim's own capture says.
757
758    ⚠️ ``verdict`` is a :class:`~ctfy.core.awd_correlation.Corroboration`
759    and not a bool, because the two ways of not finding the attacker are
760    opposite conclusions that render identically. Only ``absent`` is
761    evidence about anybody; ``unreadable`` is the default and the
762    fail-open direction. The type is on the wire, so a client cannot
763    quietly widen it to a string and index a two-entry palette with it.
764    """
765
766    tick: int = 0
767    attacker_team_id: str = ""
768    attacker_team_name: str = ""
769    victim_team_id: str = ""
770    victim_team_name: str = ""
771    service_id: str = ""
772    verdict: Corroboration = Corroboration.UNREADABLE
773    #: The attacker addresses the victim's box actually saw.
774    matched: list[str] = Field(default_factory=list)
775    #: How many distinct clients that box saw at all in the round.
776    #: ⚠️ Travels with the verdict rather than being derivable from it:
777    #: "none of 40" and "none of 1" are different findings, and a
778    #: reviewer reading `absent` without the denominator has a claim
779    #: with no size.
780    observed: int = 0

One recorded capture, and what the victim's own capture says.

⚠️ verdict is a ~ctfy.core.awd_correlation.Corroboration and not a bool, because the two ways of not finding the attacker are opposite conclusions that render identically. Only absent is evidence about anybody; unreadable is the default and the fail-open direction. The type is on the wire, so a client cannot quietly widen it to a string and index a two-entry palette with it.

tick: int = 0
attacker_team_id: str = ''
attacker_team_name: str = ''
victim_team_id: str = ''
victim_team_name: str = ''
service_id: str = ''
verdict: ctfy.core.awd_correlation.Corroboration = <Corroboration.UNREADABLE: 'unreadable'>
matched: list[str] = PydanticUndefined
observed: int = 0
class AwdClockBrief(ctfy.core.models.CtfyModel):
750class AwdClockBrief(CtfyModel):
751    """The compact round clock a list card / header progress bar draws.
752
753    ⚠️ **The same round arithmetic as ``AwdMatchStatus``, carried on the
754    *list* row** so a card can draw "round N / M · this round ends in
755    mm:ss" without a per-row ``/awd/match`` fetch — the N+1 shape
756    ``awd_match_ids`` / ``team_counts`` already remove for this
757    projection. It is a brief, not the full status: the budgets and the
758    ids on ``AwdMatchStatus`` are redundant on a row that already names
759    the competition, and a card never reads them.
760
761    ⚠️ **Never recomputed in the browser.** ``current_tick`` /
762    ``tick_ends_at`` / ``ends_at`` come off ``ctfy.core.awd`` here,
763    exactly as ``AwdMatchStatus`` documents, so the card, the header and
764    the match page cannot disagree at a round boundary — the copy that
765    re-derives ``starts_at + tick_seconds * n`` is the one that is wrong
766    at the instant a room is looking at the clock.
767
768    ⚠️ **``None`` on ``CompetitionInfo`` means "not a match / not
769    scheduled", not "round zero".** ``current_tick`` is ``None`` before
770    the first round and after the last (``core.awd`` represents the gap
771    as a real state), and ``last_tick_done`` is ``-1`` before anything
772    settles — the same sentinels the full status carries.
773    """
774
775    #: The match's kickoff — when its first round opens.
776    #:
777    # ⚠️ **The match's own ``starts_at``, never the competition's.** The two
778    # are different instants on purpose (`core.awd.provisioning_window_s`
779    # says why), and the gap is the ordinary case rather than an edge:
780    # `admin competition create --awd-starts-at …` writes a competition
781    # with no window of its own, so ``CompetitionInfo.starts_at`` is unset
782    # and the competition reads as running from the moment it is created.
783    # Without this field the brief could not say that nothing has started
784    # — ``current_tick`` is ``None`` both before the first round and after
785    # the last — and a header drew "round 0 of 12" under a 进行中 chip for
786    # the whole wait before kickoff, with no countdown anywhere.
787    #
788    # ⚠️ ``None`` only where the whole brief is (an unscheduled match); a
789    # brief exists only for a match that has a kickoff.
790    starts_at: datetime | None = None
791    #: The round length and count — the denominators the bar needs.
792    tick_seconds: int = 0
793    tick_count: int = 0
794    #: The round in progress, or ``None`` before the first and after the
795    #: last. Never stored; recomputed on every read.
796    current_tick: int | None = None
797    #: When the round in progress ends. ``None`` when none is.
798    tick_ends_at: datetime | None = None
799    #: When the last round closes — the match's own end. ``None`` when the
800    #: match has no ``starts_at`` (unscheduled), never an epoch.
801    ends_at: datetime | None = None
802    #: The last round settled — the one a board is showing. ``-1`` means
803    #: none has been, which is not round 0.
804    last_tick_done: int = -1

The compact round clock a list card / header progress bar draws.

⚠️ The same round arithmetic as AwdMatchStatus, carried on the list row so a card can draw "round N / M · this round ends in mm:ss" without a per-row /awd/match fetch — the N+1 shape awd_match_ids / team_counts already remove for this projection. It is a brief, not the full status: the budgets and the ids on AwdMatchStatus are redundant on a row that already names the competition, and a card never reads them.

⚠️ Never recomputed in the browser. current_tick / tick_ends_at / ends_at come off ctfy.core.awd here, exactly as AwdMatchStatus documents, so the card, the header and the match page cannot disagree at a round boundary — the copy that re-derives starts_at + tick_seconds * n is the one that is wrong at the instant a room is looking at the clock.

⚠️ None on CompetitionInfo means "not a match / not scheduled", not "round zero". current_tick is None before the first round and after the last (core.awd represents the gap as a real state), and last_tick_done is -1 before anything settles — the same sentinels the full status carries.

starts_at: datetime.datetime | None = None
tick_seconds: int = 0
tick_count: int = 0
current_tick: int | None = None
tick_ends_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
last_tick_done: int = -1
class AwdCorroborationResponse(ctfy.core.models.CtfyModel):
783class AwdCorroborationResponse(CtfyModel):
784    """One round's captures, ordered so the reviewable ones come first.
785
786    ⚠️ **Reports, never rules.** Nothing on this page has moved a score
787    or refused a flag; §7.1 of the AWD plan says why, and the same
788    posture as ``awd_self_submission``. It exists so an organiser can
789    look, which is the only thing the evidence supports.
790    """
791
792    competition_id: str = ""
793    tick: int = 0
794    rows: list[AwdCaptureCorroborationRow] = Field(default_factory=list)
795    #: How many rows carry each verdict, so a page that is entirely
796    #: ``unreadable`` says so at the top rather than reading as "no
797    #: findings". ⚠️ Every member is present at zero — an absent key and
798    #: a zero are the same in JSON to a client that uses `||`.
799    totals: dict[Corroboration, int] = Field(default_factory=dict)

One round's captures, ordered so the reviewable ones come first.

⚠️ Reports, never rules. Nothing on this page has moved a score or refused a flag; §7.1 of the AWD plan says why, and the same posture as awd_self_submission. It exists so an organiser can look, which is the only thing the evidence supports.

competition_id: str = ''
tick: int = 0
rows: list[AwdCaptureCorroborationRow] = PydanticUndefined
totals: dict[ctfy.core.awd_correlation.Corroboration, int] = PydanticUndefined
class AwdDefenceAnchorResponse(ctfy.core.models.CtfyModel):
921class AwdDefenceAnchorResponse(CtfyModel):
922    """The match's absolute defence scale, one row per entrant.
923
924    ⚠️ **The rows are the roster, not the recorded rows.** An entrant
925    the anchor never reached has no `awd_attacks` row at all, so a
926    listing derived from the rows would drop exactly the entrant whose
927    absence is the finding.
928    """
929
930    competition_id: str = ""
931    #: Rounds that have finished, from the match clock — the ceiling
932    #: every `attempted` should be near. ⚠️ Published beside the rows
933    #: because `attempted` alone cannot say whether a low number is a
934    #: young match or a broken attacker.
935    rounds_finished: int = 0
936    #: Whether this match runs the reference attacker at all. ⚠️ An
937    #: unarmed match answers 200 with every row at zero and this
938    #: `False`, never 404: "the anchor is off" and "the anchor found
939    #: nothing" are opposite facts, and a page cannot tell them apart
940    #: from an empty table.
941    armed: bool = False
942    rows: list[AwdDefenceAnchorRow] = Field(default_factory=list)

The match's absolute defence scale, one row per entrant.

⚠️ The rows are the roster, not the recorded rows. An entrant the anchor never reached has no awd_attacks row at all, so a listing derived from the rows would drop exactly the entrant whose absence is the finding.

competition_id: str = ''
rounds_finished: int = 0
armed: bool = False
rows: list[AwdDefenceAnchorRow] = PydanticUndefined
class AwdDefenceAnchorRow(ctfy.core.models.CtfyModel):
802class AwdDefenceAnchorRow(CtfyModel):
803    """One entrant's standing against the reference attacker (§16.6).
804
805    A model-vs-model ranking is a **relative** measurement: your SLA
806    rises against a weak field and falls against a strong one, so a bare
807    ranking does not compare across matches. This row is the absolute
808    half — every entrant meets the same attacker, the gamebox's own
809    `solution/exploit.py`, so "did they block a known exploit" means the
810    same thing in every match.
811    """
812
813    team_id: str = ""
814    #: ⚠️ **The organiser's own read, so the real name.** The route is
815    #: `/admin`-scoped and `test_anonymity_doors` puts those out of
816    #: scope deliberately — an organiser sees through anonymity, as they
817    #: do on the corroboration table beside it. Publishing this scale on
818    #: a *player* surface is a separate decision and needs the
819    #: projection; see the route.
820    team_name: str = ""
821    #: Rounds the reference exploit **actually ran** against this box.
822    #: ⚠️ Never the round count: `awd_captures` records only successes,
823    #: so `rounds − captures` credits an entrant for every round the
824    #: platform failed to attack them.
825    attempted: int = 0
826    held: int = 0
827    breached: int = 0
828    #: Rounds whose row says the attacker did not run. Published because
829    #: it is how much of the match the platform failed to measure, and
830    #: it is *ours* rather than the entrant's.
831    not_attempted: int = 0
832    #: The two tables disagreeing — a reference capture in a round that
833    #: does not say `ran`. Zero on every healthy deployment; a non-zero
834    #: value is a bug in the round loop, and it counts toward neither
835    #: `held` nor `breached`.
836    inconsistent: int = 0
837    #: The newest round this entrant has a row for, or ``-1`` for an
838    #: entrant the anchor never reached. ⚠️ ``-1`` rather than ``0``:
839    #: round 0 is a real round.
840    last_tick: int = -1
841    #: That round's refusal, if the platform declined to attack: no
842    #: reference exploit shipped, an unresolvable box, a container
843    #: docker would not start.
844    last_message: str = ""
845    #: That round's own account of itself — what the attack container
846    #: said on its way down.
847    #:
848    #: ⚠️ **Without it `held` is unreadable.** A round that ran and took
849    #: nothing is a defender who patched the hole *and* an exploit of
850    #: ours that no longer works; both are counted into `held`, so
851    #: `8 attempted / 8 held / 0 breached` is either a perfect defence
852    #: or a broken attacker. Read across the field at once the sentence
853    #: settles it: one shared by every entrant is ours, one entrant's
854    #: alone is theirs.
855    #:
856    #: ⚠️ **Organiser-only, like `team_name` above.** It is the attack
857    #: container's own output, so it is `raw_traffic_page`'s stance
858    #: rather than `player_traffic_page`'s — evidence for a console,
859    #: never a line an entrant reads. A player-facing §16.6 scale is a
860    #: separate decision and this field does not travel with it.
861    last_diagnostic: str = ""
862    #: The newest round's **live-defence verdict** — the SHELL-mode
863    #: reading of §5.4's external-probe checker, composed the way
864    #: `judge_patch` composes a submitted patch (`ctfy.core.awd_live_defence`).
865    #:
866    #: ⚠️ **`held` alone conflated two opposite defences, and this is the
867    #: half `last_diagnostic` does not name.** A round the reference
868    #: exploit ran and took nothing counts as `held` whether the box was
869    #: patched or its service was **switched off** — the latter blocks the
870    #: exploit exactly as `judge_patch`'s "delete the feature" case does,
871    #: which is why `judge_patch` decides functionality first. This is
872    #: that decision on the live round: `functionality_broken` when a
873    #: held round's service was down, `fixed` when it was up, `not_fixed`
874    #: on a breach, `inconclusive` for a round the exploit never ran.
875    #:
876    #: ⚠️ **Defaults `inconclusive`** — an entrant the exploit never
877    #: reached, and an older server that does not send the field, both
878    #: read as *unmeasured* rather than as a fix. Its `earns_defence` is
879    #: `False`, so the fail-open default never flatters a defender.
880    last_verdict: Verdict = Verdict.INCONCLUSIVE
881    #: Of the `held` rounds, those held with the service **up** — a
882    #: genuine fix (`Verdict.FIXED`). This is the number `held` was meant
883    #: to be; `held - held_serving - held_broken` is the handful with no
884    #: SLA row that round (unmeasured functionality).
885    held_serving: int = 0
886    #: Of the `held` rounds, those held only because a service was
887    #: **down** (`Verdict.FUNCTIONALITY_BROKEN`) — the exploit took
888    #: nothing because there was nothing to take. A non-zero value here
889    #: beside a high `held` is a defender who unplugged, not one who
890    #: patched, and the SLA column already debits them for it.
891    held_broken: int = 0
892    #: Rounds this entrant's box was **cut off from the arena** — the
893    #: fabric probe found no other node could reach it (#1687).
894    #:
895    #: ⚠️ **Information only; it changes nothing above.** The reference
896    #: attacker runs through the box's own node, so a hold it measured in
897    #: such a round is a real hold and stays in `held` / `held_serving`.
898    #: What the cut-off does corrupt is the *melee* board, where rivals
899    #: attack across the fabric — that is where the correction lives
900    #: (`settle_tick`). Defaults ``0``: an older server knew of no cut-offs.
901    cut_off_rounds: int = 0
902
903    @computed_field
904    @property
905    def hold_rate(self) -> float | None:
906        """Share of attempted rounds held, or `None` for none attempted.
907
908        ⚠️ **`None`, never `0.0`.** A zero asserts this entrant was
909        breached every round, which is the opposite of what an
910        unmeasured one deserves — `comply_rate`'s rule on
911        `/admin/deception/stats`. ⚠️ A `computed_field` rather than a
912        plain property because this is a *wire* model: the client has
913        both halves and could divide, and then two readers disagree
914        about the rounding of the number the whole scale is.
915        """
916        if self.attempted <= 0:
917            return None
918        return self.held / self.attempted

One entrant's standing against the reference attacker (§16.6).

A model-vs-model ranking is a relative measurement: your SLA rises against a weak field and falls against a strong one, so a bare ranking does not compare across matches. This row is the absolute half — every entrant meets the same attacker, the gamebox's own solution/exploit.py, so "did they block a known exploit" means the same thing in every match.

team_id: str = ''
team_name: str = ''
attempted: int = 0
held: int = 0
breached: int = 0
not_attempted: int = 0
inconsistent: int = 0
last_tick: int = -1
last_message: str = ''
last_diagnostic: str = ''
last_verdict: ctfy.core.enums.Verdict = <Verdict.INCONCLUSIVE: 'inconclusive'>
held_serving: int = 0
held_broken: int = 0
cut_off_rounds: int = 0
hold_rate: float | None
903    @computed_field
904    @property
905    def hold_rate(self) -> float | None:
906        """Share of attempted rounds held, or `None` for none attempted.
907
908        ⚠️ **`None`, never `0.0`.** A zero asserts this entrant was
909        breached every round, which is the opposite of what an
910        unmeasured one deserves — `comply_rate`'s rule on
911        `/admin/deception/stats`. ⚠️ A `computed_field` rather than a
912        plain property because this is a *wire* model: the client has
913        both halves and could divide, and then two readers disagree
914        about the rounding of the number the whole scale is.
915        """
916        if self.attempted <= 0:
917            return None
918        return self.held / self.attempted

Share of attempted rounds held, or None for none attempted.

⚠️ None, never 0.0. A zero asserts this entrant was breached every round, which is the opposite of what an unmeasured one deserves — comply_rate's rule on /admin/deception/stats. ⚠️ A computed_field rather than a plain property because this is a wire model: the client has both halves and could divide, and then two readers disagree about the rounding of the number the whole scale is.

class AwdDefenceTimelineCell(ctfy.core.models.CtfyModel):
1197class AwdDefenceTimelineCell(CtfyModel):
1198    """One entrant's live-defence verdict for one round."""
1199
1200    tick: int = 0
1201    #: The round's verdict, a :class:`ctfy.core.enums.Verdict` value:
1202    #: ``fixed`` (held and serving — a genuine live fix), ``not_fixed``
1203    #: (breached), ``functionality_broken`` (held only because the box
1204    #: was down) or ``inconclusive`` (unmeasured). ⚠️ **Typed on the
1205    #: wire, never ``str``** — a "did they fix it?" heatmap keys a colour
1206    #: off this, and a comment-only member list is a claim ``tsc`` cannot
1207    #: check. The frontend reads it with a computed key
1208    #: (``t(`defence.verdict.${cell.verdict}`)``), so it is pinned from
1209    #: Python against the enum in ``test_awd_defence_timeline_copy_parity``.
1210    verdict: Verdict = Verdict.INCONCLUSIVE
1211    #: The raw signals the verdict was composed from, so the reasons panel
1212    #: shows *why* it landed and not only the label. The verdict is a fold
1213    #: of these; they are on the wire beside it, never re-derived by a
1214    #: client (that would be a second composition to disagree with).
1215    #:
1216    #: ⚠️ **Their meaning is per mode, and the docstring is the only place
1217    #: it reaches a client.** SHELL (`awd_live_defence.defence_timeline`)
1218    #: reads a live box each round: ``served`` is the SLA probe —
1219    #: ``true`` (every service up), ``false`` (one down — the box broke),
1220    #: ``null`` (no probe ran — unmeasured); ``exploit_ran`` is always
1221    #: ``true`` (only rounds the reference exploit ran are cells);
1222    #: ``breached`` is whether it captured a flag. PATCH
1223    #: (`defence_matrix.patch_defence_timeline`) has no live round, so the
1224    #: verdict comes from judging a submitted patch: ``served`` is
1225    #: ``null`` (never probed per round), ``exploit_ran`` is ``false`` (no
1226    #: exploit executed), and ``breached`` is ``true`` **iff** the verdict
1227    #: is ``not_fixed`` — the one signal a patch judgment attests.
1228    served: bool | None = None
1229    exploit_ran: bool = False
1230    breached: bool = False

One entrant's live-defence verdict for one round.

tick: int = 0
verdict: ctfy.core.enums.Verdict = <Verdict.INCONCLUSIVE: 'inconclusive'>
served: bool | None = None
exploit_ran: bool = False
breached: bool = False
class AwdDefenceTimelineResponse(ctfy.core.models.CtfyModel):
1257class AwdDefenceTimelineResponse(CtfyModel):
1258    """The per-round "did they fix it?" series, one row per entrant.
1259
1260    ⚠️ **``applicable`` is the mode gate, and ``False`` is not an error.**
1261    Both SHELL (the live-box verdict per round) and PATCH (the round a
1262    submission landed in, sticky from there) carry a real series now;
1263    ``applicable`` is ``False`` only when ``defence_mode`` is a value this
1264    build does not recognise, so a client shows the mode instead of
1265    drawing an empty heatmap. A client draws the heatmap only when
1266    ``applicable`` is true.
1267
1268    ⚠️ **``rows`` is the roster, never the verdict rows** — an entrant the
1269    exploit never reached appears with an empty ``cells`` list, the same
1270    trap :class:`AwdDefenceAnchorResponse` records.
1271    """
1272
1273    competition_id: str = ""
1274    #: Which defence mode this match runs — ``shell`` (the live-box verdict
1275    #: per round) or ``patch`` (the round each submission landed in). Both
1276    #: carry a real series.
1277    defence_mode: AwdDefenceMode = AwdDefenceMode.PATCH
1278    #: Whether ``rows`` carries a real per-round series. ``False`` only when
1279    #: ``defence_mode`` is unrecognised by this build.
1280    applicable: bool = False
1281    #: The rounds any entrant was attacked in, ascending — the heatmap's
1282    #: column axis, published once so the grid is filled from one order
1283    #: rather than the union of the rows. ``-1`` sentinels are never here;
1284    #: an empty list is a match with nothing measured yet.
1285    ticks: list[int] = Field(default_factory=list)
1286    rows: list[AwdDefenceTimelineRow] = Field(default_factory=list)

The per-round "did they fix it?" series, one row per entrant.

⚠️ applicable is the mode gate, and False is not an error. Both SHELL (the live-box verdict per round) and PATCH (the round a submission landed in, sticky from there) carry a real series now; applicable is False only when defence_mode is a value this build does not recognise, so a client shows the mode instead of drawing an empty heatmap. A client draws the heatmap only when applicable is true.

⚠️ rows is the roster, never the verdict rows — an entrant the exploit never reached appears with an empty cells list, the same trap AwdDefenceAnchorResponse records.

competition_id: str = ''
defence_mode: ctfy.core.enums.AwdDefenceMode = <AwdDefenceMode.PATCH: 'patch'>
applicable: bool = False
ticks: list[int] = PydanticUndefined
rows: list[AwdDefenceTimelineRow] = PydanticUndefined
class AwdDefenceTimelineRow(ctfy.core.models.CtfyModel):
1233class AwdDefenceTimelineRow(CtfyModel):
1234    """One entrant's whole per-round defence series.
1235
1236    ⚠️ **Which rounds are cells depends on the mode, and neither is one
1237    per match round.** SHELL cells are the rounds the reference exploit
1238    *ran* (a round the platform could not reach is a gap in our
1239    measurement, absent rather than a stored ``inconclusive``); PATCH
1240    cells run from the team's first submission round to the match's last,
1241    because a landed patch is sticky. Either way the renderer fills the
1242    grid from ``ticks`` on the response. An entrant with no cells — the
1243    exploit never reached them (SHELL), or they never submitted a
1244    placeable patch (PATCH) — is the most informative row on the heatmap.
1245    """
1246
1247    team_id: str = ""
1248    team_name: str = ""
1249    #: ⚠️ A wire model naming a team needs a stated fate, like every
1250    #: neighbouring standing row — ``anonymise_defence_timeline`` writes
1251    #: it, and a client reading a missing field as anonymous would tag the
1252    #: whole field.
1253    anonymous: bool = False
1254    cells: list[AwdDefenceTimelineCell] = Field(default_factory=list)

One entrant's whole per-round defence series.

⚠️ Which rounds are cells depends on the mode, and neither is one per match round. SHELL cells are the rounds the reference exploit ran (a round the platform could not reach is a gap in our measurement, absent rather than a stored inconclusive); PATCH cells run from the team's first submission round to the match's last, because a landed patch is sticky. Either way the renderer fills the grid from ticks on the response. An entrant with no cells — the exploit never reached them (SHELL), or they never submitted a placeable patch (PATCH) — is the most informative row on the heatmap.

team_id: str = ''
team_name: str = ''
anonymous: bool = False
cells: list[AwdDefenceTimelineCell] = PydanticUndefined
class AwdEventRow(ctfy.core.models.CtfyModel):
181class AwdEventRow(CtfyModel):
182    """One settled capture on the spectator "赛况" feed.
183
184    A capture is a fact about a round (``core.state.AwdCaptureState``);
185    ``core.awd_events`` orders the settled ones newest-first and tags the
186    two first-times, and the route projects the names + anonymity here.
187
188    ⚠️ **Carries both parties' identities and both anonymity bools**,
189    because an event names two teams and either may be withheld — the
190    projection aliases a withheld team and drops a hidden one, the same
191    rule ``anonymise_awd_board`` applies to a single row's own team.
192
193    ⚠️ **`first_blood` / `first_breach` are display facts, computed over
194    team-vs-team captures only.** A platform anchor is labelled
195    (`attacker_is_platform` / `victim_is_reference`) and never carries
196    either flag — §16.6's anchors are an instrument reading, not play.
197    """
198
199    tick: int = 0
200    at_ts: AwareDatetime | None = None
201    attacker_team_id: str = ""
202    attacker_name: str = ""
203    attacker_anonymous: bool = False
204    victim_team_id: str = ""
205    victim_name: str = ""
206    victim_anonymous: bool = False
207    service_id: str = ""
208    #: The single match-first team-vs-team capture.
209    first_blood: bool = False
210    #: This victim's first settled breach (team-vs-team).
211    first_breach: bool = False
212    #: The reference-attacker / reference-victim anchors — a label for
213    #: the row, never counted as first blood.
214    attacker_is_platform: bool = False
215    victim_is_reference: bool = False

One settled capture on the spectator "赛况" feed.

A capture is a fact about a round (core.state.AwdCaptureState); core.awd_events orders the settled ones newest-first and tags the two first-times, and the route projects the names + anonymity here.

⚠️ Carries both parties' identities and both anonymity bools, because an event names two teams and either may be withheld — the projection aliases a withheld team and drops a hidden one, the same rule anonymise_awd_board applies to a single row's own team.

⚠️ first_blood / first_breach are display facts, computed over team-vs-team captures only. A platform anchor is labelled (attacker_is_platform / victim_is_reference) and never carries either flag — §16.6's anchors are an instrument reading, not play.

tick: int = 0
at_ts: pydantic.types.AwareDatetime | None = None
attacker_team_id: str = ''
attacker_name: str = ''
attacker_anonymous: bool = False
victim_team_id: str = ''
victim_name: str = ''
victim_anonymous: bool = False
service_id: str = ''
first_blood: bool = False
first_breach: bool = False
attacker_is_platform: bool = False
victim_is_reference: bool = False
class AwdEventsResponse(ctfy.core.models.CtfyModel):
218class AwdEventsResponse(CtfyModel):
219    """The recent settled captures — the landing panel's "赛况" list.
220
221    ⚠️ **Settled rounds only** (`last_tick` is `last_tick_done`): the
222    round in progress carries live captures whose flags are still worth
223    points, so it is never on this feed. `-1` before the first
224    settlement, when the list is legitimately empty.
225    """
226
227    competition_id: str = ""
228    last_tick: int = -1
229    rows: list[AwdEventRow] = Field(default_factory=list)

The recent settled captures — the landing panel's "赛况" list.

⚠️ Settled rounds only (last_tick is last_tick_done): the round in progress carries live captures whose flags are still worth points, so it is never on this feed. -1 before the first settlement, when the list is legitimately empty.

competition_id: str = ''
last_tick: int = -1
rows: list[AwdEventRow] = PydanticUndefined
class AwdFlagResult(ctfy.core.models.CtfyModel):
70class AwdFlagResult(CtfyModel):
71    """What happened to one submitted value.
72
73    ``verdict`` is a :class:`ctfy.core.awd.AttackVerdict` value, plus the
74    route-level ``already_captured`` — which is not a judging outcome
75    (``judge_attack`` is pure and cannot know what is on record) but is
76    the answer a client needs to stop resending a flag it banked.
77
78    ⚠️ **Refusals carry no victim.** ``victim_team_id`` feeds a score
79    debit, so naming a team on a rejected submission would take a point
80    off them because somebody else pasted the wrong string.
81    """
82
83    #: ⚠️ **The superset used to live only in the paragraph above.** The
84    #: field was ``str``, so the two producers disagreed in type without
85    #: saying so — the refusal path passes an ``AttackVerdict`` member
86    #: and the accepted path had to write ``str(AttackVerdict.ACCEPTED)``
87    #: to make the branches agree, which is the ceremony an untyped seam
88    #: leaves behind.
89    verdict: AttackVerdict | Literal["already_captured", ""] = ""
90    victim_team_id: str = ""
91    service_id: str = ""
92    #: The round the *flag* belongs to, which for a grace-window capture
93    #: is not the current one. ``-1`` when nothing was resolved.
94    tick: int = -1

What happened to one submitted value.

verdict is a ctfy.core.awd.AttackVerdict value, plus the route-level already_captured — which is not a judging outcome (judge_attack is pure and cannot know what is on record) but is the answer a client needs to stop resending a flag it banked.

⚠️ Refusals carry no victim. victim_team_id feeds a score debit, so naming a team on a rejected submission would take a point off them because somebody else pasted the wrong string.

verdict: Union[ctfy.core.awd.AttackVerdict, Literal['already_captured', '']] = ''
victim_team_id: str = ''
service_id: str = ''
tick: int = -1
class AwdFlagSubmitRequest(ctfy.core.models.CtfyModel):
59class AwdFlagSubmitRequest(CtfyModel):
60    """One team's captures, submitted together.
61
62    ⚠️ **In the body, never a query string** — same reasoning as
63    ``POST /admin/answer-trace``: a flag in a URL is a flag in every
64    access log between the player and us.
65    """
66
67    flags: list[str] = Field(min_length=1, max_length=MAX_FLAGS_PER_SUBMISSION)

One team's captures, submitted together.

⚠️ In the body, never a query string — same reasoning as POST /admin/answer-trace: a flag in a URL is a flag in every access log between the player and us.

flags: list[str] = PydanticUndefined
class AwdFlagSubmitResponse(ctfy.core.models.CtfyModel):
 97class AwdFlagSubmitResponse(CtfyModel):
 98    """One result per submitted value, in the order they were sent.
 99
100    Positional rather than keyed by flag: a client may legitimately
101    submit a value it cannot match back (a typo, a truncated read), and
102    a short or reordered array silently misattributes every verdict
103    after the first gap.
104    """
105
106    #: The round the batch was judged in — ``-1`` when the match is not
107    #: in one. It answers "which round am I in" once for the whole
108    #: request, so the per-flag results stay uniform even then.
109    tick: int = -1
110    results: list[AwdFlagResult] = Field(default_factory=list)

One result per submitted value, in the order they were sent.

Positional rather than keyed by flag: a client may legitimately submit a value it cannot match back (a typo, a truncated read), and a short or reordered array silently misattributes every verdict after the first gap.

tick: int = -1
results: list[AwdFlagResult] = PydanticUndefined
class AwdForensicAnalysis(ctfy.core.models.CtfyModel):
1289class AwdForensicAnalysis(CtfyModel):
1290    """What a model said about one defender's write-up (§4.3.1).
1291
1292    The other half of the forensic score, beside the marker count on the
1293    row this hangs off. That one asks what the defender *found*; this
1294    asks how far their report *went* — a root cause located, a timeline
1295    reconstructed, persistence accounted for.
1296
1297    ⚠️ **Depth, never accuracy.** With no groundtruth a model cannot say
1298    whether a stated root cause is *right*, only whether the report
1299    states one specifically — so every value here is a reading of the
1300    document. `ctfy.core.awd_forensic_grading` argues it at length; the
1301    reason it matters on the wire is that a reader seeing
1302    ``root_cause: specific`` must not take it as the platform agreeing
1303    with the defender.
1304    """
1305
1306    #: Rubric id → depth (``specific`` / ``general`` / ``absent``). ⚠️
1307    #: **The appealable fact**, and the counts cannot substitute: an
1308    #: appeal asks *which* dimension you said I skipped, and a total has
1309    #: no answer to that. `ExerciseReportGradeState.verdicts`' rule.
1310    depths: dict[str, str] = Field(default_factory=dict)
1311    #: What the depths add up to under the platform's own tariff.
1312    #: ⚠️ Published rather than left to the client: the tariff is a
1313    #: server constant, so a browser deriving this would be a second
1314    #: copy of it — `discovery_rate`'s reason one model down.
1315    earned: int = 0
1316    #: What a report answering every graded dimension specifically would
1317    #: earn. ⚠️ **Over the dimensions this row was graded on**, never
1318    #: today's rubric: a denominator read off the current constant
1319    #: re-scores every historical row downward the moment the rubric
1320    #: grows.
1321    ceiling: int = 0
1322    #: Which saved version was read. ⚠️ The report is revisable while the
1323    #: match runs, so a reading that did not name its version describes a
1324    #: document that may no longer exist.
1325    report_version: int = 0
1326    graded_at: AwareDatetime | None = None
1327
1328    @computed_field
1329    @property
1330    def rate(self) -> float | None:
1331        """``earned / ceiling``, or ``None`` when nothing was graded.
1332
1333        ⚠️ **``None``, never ``0.0``** — `discovery_rate`'s rule on the
1334        other half of the same score. A reading whose stored depths are
1335        all unreadable graded nothing, and publishing that as a zero is
1336        a reading of our own plumbing wearing the defender's name.
1337        """
1338        if self.ceiling <= 0:
1339            return None
1340        return self.earned / self.ceiling

What a model said about one defender's write-up (§4.3.1).

The other half of the forensic score, beside the marker count on the row this hangs off. That one asks what the defender found; this asks how far their report went — a root cause located, a timeline reconstructed, persistence accounted for.

⚠️ Depth, never accuracy. With no groundtruth a model cannot say whether a stated root cause is right, only whether the report states one specifically — so every value here is a reading of the document. ctfy.core.awd_forensic_grading argues it at length; the reason it matters on the wire is that a reader seeing root_cause: specific must not take it as the platform agreeing with the defender.

depths: dict[str, str] = PydanticUndefined
earned: int = 0
ceiling: int = 0
report_version: int = 0
graded_at: pydantic.types.AwareDatetime | None = None
rate: float | None
1328    @computed_field
1329    @property
1330    def rate(self) -> float | None:
1331        """``earned / ceiling``, or ``None`` when nothing was graded.
1332
1333        ⚠️ **``None``, never ``0.0``** — `discovery_rate`'s rule on the
1334        other half of the same score. A reading whose stored depths are
1335        all unreadable graded nothing, and publishing that as a zero is
1336        a reading of our own plumbing wearing the defender's name.
1337        """
1338        if self.ceiling <= 0:
1339            return None
1340        return self.earned / self.ceiling

earned / ceiling, or None when nothing was graded.

⚠️ None, never 0.0 — discovery_rate's rule on the other half of the same score. A reading whose stored depths are all unreadable graded nothing, and publishing that as a zero is a reading of our own plumbing wearing the defender's name.

class AwdForensicsResponse(ctfy.core.models.CtfyModel):
1405class AwdForensicsResponse(CtfyModel):
1406    """The match's forensic discovery standing, one row per entrant.
1407
1408    ⚠️ **The rows are the roster, never the marker rows.** An entrant
1409    the attacker never reached has no marker at all, so a listing
1410    derived from the rows drops exactly the team whose absence is the
1411    finding — `AwdDefenceAnchorResponse` records the same trap.
1412    """
1413
1414    competition_id: str = ""
1415    #: The gamebox whose report is being scored. ⚠️ Published because a
1416    #: report is per `(team, competition, challenge)` and a reader who
1417    #: cannot see which challenge was read cannot tell an empty column
1418    #: from one scored against the wrong document.
1419    challenge_id: str = ""
1420    #: Rounds that have finished, from the match clock. ⚠️ Beside the
1421    #: rows for `rounds_finished`' reason on the anchors: `offered`
1422    #: alone cannot say whether a low number is a young match or an
1423    #: attacker that stopped landing.
1424    rounds_finished: int = 0
1425    #: Whether this match's attacker carries a marker at all. ⚠️ An
1426    #: attacker with no environment mints none, so every row would read
1427    #: `offered: 0` — identical to a field the attacker reached and
1428    #: nobody traced. `armed: False` is what separates them, and it is
1429    #: `carries_env` rather than "is there an attacker".
1430    armed: bool = False
1431    #: Whether this deployment can run the model half at all. ⚠️ **A
1432    #: column of empty `analysis` cells has two causes** — nobody has
1433    #: pressed grade, or there is no gateway to press it with — and only
1434    #: the first is something an organiser can act on. `ReportPipeline`'s
1435    #: `grading_configured` exists for exactly this, and its own note
1436    #: says why: a backlog nothing drains is byte-identical to a busy
1437    #: one.
1438    #:
1439    #: ⚠️ **Three-valued, and ``False`` as the default was a claim.**
1440    #: ``None`` is *this server did not say*; the field is composed on
1441    #: every response this server builds, so the only producer of
1442    #: ``None`` is an **older platform** a newer client is pointed at.
1443    #: The two readers materialise that absence differently — a browser
1444    #: reads raw JSON and sees ``undefined``, the CLI reads through this
1445    #: model and would see the default — so a ``False`` default printed
1446    #: *"no LLM gateway on this deployment"* at an operator whose
1447    #: deployment has one, and sent them to configure it again.
1448    #: `MyEvalRunRow.usage_source`'s recorded defect: a default under a
1449    #: comment claiming it asserts nothing, on the reader that turns it
1450    #: into an assertion. Both readers test ``is False`` / ``=== false``.
1451    analysis_configured: bool | None = None
1452    rows: list[AwdForensicsRow] = Field(default_factory=list)

The match's forensic discovery standing, one row per entrant.

⚠️ The rows are the roster, never the marker rows. An entrant the attacker never reached has no marker at all, so a listing derived from the rows drops exactly the team whose absence is the finding — AwdDefenceAnchorResponse records the same trap.

competition_id: str = ''
challenge_id: str = ''
rounds_finished: int = 0
armed: bool = False
analysis_configured: bool | None = None
rows: list[AwdForensicsRow] = PydanticUndefined
class AwdForensicsRow(ctfy.core.models.CtfyModel):
1343class AwdForensicsRow(CtfyModel):
1344    """One entrant's discovery half of the forensic score (§4.3.1).
1345
1346    The platform's agent attacker carries a per-round marker into every
1347    box; a defender who instrumented their own box can find where the
1348    attacker touched it and quote the value back in their report. This
1349    row is how much of what reached them they found.
1350
1351    ⚠️ **This half is deliberately the shallow one.** "Did you keep a
1352    log and did you read it" is a real blue-team floor and worth
1353    scoring; §4.3's warning against letting a forensic range become a
1354    grep exercise is about the *whole* score stopping here, which is
1355    what the model-judged half exists to answer.
1356    """
1357
1358    team_id: str = ""
1359    #: ⚠️ **The organiser's own read, so the real name.** `/admin`-scoped
1360    #: exactly like the two §16.6 anchors, and publishing any of the
1361    #: three on a *player* surface is one decision needing
1362    #: `ctfy.server.anonymity` rather than three.
1363    team_name: str = ""
1364    #: Markers this team quoted that were really minted for them.
1365    found: int = 0
1366    #: Markers that reached this box — the denominator. ⚠️ **Never the
1367    #: round count.** The attacker carrying these is an LLM and
1368    #: therefore random, so a round it never landed in is a round the
1369    #: platform failed to attack; `awd_attacks` makes the same argument
1370    #: for §16.6's defence scale, and charging the team for it turns
1371    #: this into a reading of our own uptime wearing their name.
1372    offered: int = 0
1373    #: Whether this team has saved a report at all. ⚠️ **Annotates,
1374    #: never decides**: a team with no report and a team whose report
1375    #: quotes nothing both score zero, and correctly — what differs is
1376    #: the *repair*, so the two blank cells get different advice.
1377    #: `AuthoredReceptionState`'s split.
1378    reported: bool = False
1379    #: The model half's reading of this entrant's report, or ``None``
1380    #: when there is not one. ⚠️ **``None``, never an all-``absent``
1381    #: reading**: a report nobody has graded and one a model read and
1382    #: found nothing in are opposite facts with opposite repairs (press
1383    #: grade / read the report), and a zero-shaped placeholder makes the
1384    #: first wear the second's clothes.
1385    analysis: AwdForensicAnalysis | None = None
1386
1387    @computed_field
1388    @property
1389    def discovery_rate(self) -> float | None:
1390        """Share of what reached them that they found, or `None`.
1391
1392        ⚠️ **`None`, never `0.0`** — `hold_rate`'s rule, and it binds
1393        harder here: an entrant the attacker never reached would
1394        otherwise publish at zero for rounds nobody ran against them.
1395        ⚠️ A `computed_field` rather than a plain property because this
1396        is a *wire* model and the client holds both halves: two readers
1397        dividing it their own way is how a page and a command come to
1398        disagree about one entrant.
1399        """
1400        if self.offered <= 0:
1401            return None
1402        return self.found / self.offered

One entrant's discovery half of the forensic score (§4.3.1).

The platform's agent attacker carries a per-round marker into every box; a defender who instrumented their own box can find where the attacker touched it and quote the value back in their report. This row is how much of what reached them they found.

⚠️ This half is deliberately the shallow one. "Did you keep a log and did you read it" is a real blue-team floor and worth scoring; §4.3's warning against letting a forensic range become a grep exercise is about the whole score stopping here, which is what the model-judged half exists to answer.

team_id: str = ''
team_name: str = ''
found: int = 0
offered: int = 0
reported: bool = False
analysis: AwdForensicAnalysis | None = None
discovery_rate: float | None
1387    @computed_field
1388    @property
1389    def discovery_rate(self) -> float | None:
1390        """Share of what reached them that they found, or `None`.
1391
1392        ⚠️ **`None`, never `0.0`** — `hold_rate`'s rule, and it binds
1393        harder here: an entrant the attacker never reached would
1394        otherwise publish at zero for rounds nobody ran against them.
1395        ⚠️ A `computed_field` rather than a plain property because this
1396        is a *wire* model and the client holds both halves: two readers
1397        dividing it their own way is how a page and a command come to
1398        disagree about one entrant.
1399        """
1400        if self.offered <= 0:
1401            return None
1402        return self.found / self.offered

Share of what reached them that they found, or None.

⚠️ None, never 0.0 — hold_rate's rule, and it binds harder here: an entrant the attacker never reached would otherwise publish at zero for rounds nobody ran against them. ⚠️ A computed_field rather than a plain property because this is a wire model and the client holds both halves: two readers dividing it their own way is how a page and a command come to disagree about one entrant.

class AwdMatchConfig(ctfy.core.models.CtfyModel):
232class AwdMatchConfig(CtfyModel):
233    """What an organiser may set on a match.
234
235    ⚠️ **`last_tick_done` is deliberately absent, and that absence is a
236    control rather than an omission.** It is the tick loop's bookmark —
237    "has this round been settled" — and a body carrying it lets one save
238    rewind the loop, which then re-probes SLA and re-settles rounds the
239    board has already published *and cached as final* (the board's cache
240    keys on the resolved round precisely because a settled round never
241    changes again). The route preserves the stored value; nothing on the
242    wire can move it.
243
244    ``created_at`` is absent for the smaller version of the same reason:
245    it is a fact about the row, not a setting.
246
247    ⚠️ **`extra="forbid"`, so sending one is a 422 rather than a silent
248    drop.** The route preserves the stored bookmark either way, so this
249    buys nothing mechanically — it buys the client being *told*. A
250    caller that believes it can rewind the loop and gets a 200 goes on
251    believing it, and the next thing it writes may not be against a
252    model that ignores the field.
253    """
254
255    model_config = ConfigDict(extra="forbid")
256
257    #: The gamebox every team runs. Allowed empty — a match under
258    #: construction is a real state, and `AwdMatchState` says so — but
259    #: `plan_provision` refuses without one, which is where an organiser
260    #: hears about it while they can still act.
261    challenge_id: str = ""
262    #: §16.6's other anchor: run one extra gamebox that belongs to
263    #: nobody, is never patched, and every entrant may attack.
264    #:
265    #: ⚠️ **Taking it scores nothing**, deliberately: an unpatched box
266    #: that never fights back is the cheapest target in the arena, so
267    #: paying for it would make "farm the free box" the dominant opening
268    #: and move the relative column this anchor exists to sit beside.
269    #: What it buys is an absolute *attack* scale — every entrant, in
270    #: every match, meets the same target.
271    #:
272    #: ⚠️ It costs one more box out of the arena's capacity, and §4.5
273    #: fixes that supply at kickoff, so this is frozen once the match
274    #: starts along with `attacker`.
275    reference_victim: bool = False
276    #: Per-match overrides for the three scoring knobs. Empty means the
277    #: packaged defaults, so an organiser who says nothing gets today's
278    #: behaviour exactly.
279    #:
280    #: ⚠️ Validated here rather than trusted: an unknown key is a 422,
281    #: because the alternative is a knob that reads as set and does
282    #: nothing. The *read* side stays lenient on purpose — see
283    #: `AwdScoringConfig.from_params`.
284    #:
285    #: ⚠️ **That was a claim, not a fact, until `zero_sum` arrived.**
286    #: `dict[str, float]` bounds the *values* and says nothing about the
287    #: keys, so `{"nonsense": 1.0}` was accepted and did precisely what
288    #: the sentence above says it must not — measured, not read. The
289    #: validator below is the sentence made true, and it derives the key
290    #: set from `AwdScoringConfig` rather than restating it, so a knob
291    #: added there is accepted here without anyone remembering.
292    #:
293    #: ⚠️ **`bool | float`, and the order is load-bearing.** `zero_sum`
294    #: is a switch, and against a bare `float` pydantic silently coerces
295    #: `true` to `1.0` — after which `from_params` reads a float where it
296    #: wants a bool, drops it, and the switch is off with nothing
297    #: anywhere saying so. That is the "built with no door" shape this
298    #: repo keeps recording, and it would have shipped invisibly.
299    scoring_params: dict[str, bool | float] = Field(default_factory=dict)
300
301    @field_validator("scoring_params")
302    @classmethod
303    def _known_scoring_knobs(cls, value: dict[str, bool | float]) -> dict[str, bool | float]:
304        known = {f.name for f in fields(AwdScoringConfig)}
305        unknown = sorted(set(value) - known)
306        if unknown:
307            raise ValueError(
308                f"unknown scoring knob(s): {', '.join(unknown)}; known: {', '.join(sorted(known))}"
309            )
310        return value
311
312    #: What attacks every gamebox each round. ``none`` is a classic
313    #: match, where the entrants attack each other.
314    #:
315    #: ⚠️ **One field where there were two, and the pair was reachable.**
316    #: ``platform_attacker: bool`` said *whether* and this said *what*,
317    #: so ``(False, "agent")`` could be stored — no writer refused it —
318    #: and it produced a match that reads as configured and attacks
319    #: nobody. ``AwdAttacker`` carries the measurement.
320    #:
321    #: ⚠️ **This is the field the whole format turns on, and for a while
322    #: it had no door**: the column, the migration and the tick loop all
323    #: landed without one reaching here, so nothing outside a direct
324    #: backend call could arm a match. `test_awd_match_settings_reachable`
325    #: pins the property rather than that instance.
326    #:
327    #: ⚠️ Strict here and a plain `str` on the state, the same split
328    #: `scoring_params` uses: the write path refuses a typo while an
329    #: organiser can still act, and the read path never fails on a value
330    #: a later release renamed.
331    attacker: AwdAttacker = AwdAttacker.NONE
332    #: How defenders fix their box — see ``AwdDefenceMode``. ``shell``
333    #: fixes the live box over the defender shell (scored by the round
334    #: loop already); ``patch`` submits to the judged-patch pipeline.
335    #:
336    #: ⚠️ **Strict here and a plain ``str`` on the state**, the same split
337    #: ``attacker`` uses: the write path refuses a typo while an organiser
338    #: can still act, the read path never fails on a value a later release
339    #: renamed. Defaults to ``patch`` — the compatibility default, so an
340    #: API caller who omits it keeps today's behaviour; the wizard sends
341    #: ``shell`` explicitly to steer a new event to live defence.
342    defence_mode: AwdDefenceMode = AwdDefenceMode.PATCH
343    #: Under ``defence_mode == shell``, which SSH transport the box
344    #: exposes — see ``AwdShellExposure``. ``managed`` is the platform's
345    #: cert → bastion → ``docker exec`` shell on ``mgmtnet`` (owner only);
346    #: ``open`` runs ``sshd`` in the app container on the game network, so
347    #: every team in the match can reach it and SSH access is itself an
348    #: attack.
349    #:
350    #: ⚠️ **Strict here and a plain ``str`` on the state**, the split
351    #: ``defence_mode`` / ``attacker`` use. Defaults ``managed`` — the
352    #: compatibility default, so an API caller who omits it keeps today's
353    #: controlled shell; the wizard offers ``open`` for a classic-AWD
354    #: event. Inert when ``defence_mode`` is ``patch`` (no live shell).
355    shell_exposure: AwdShellExposure = AwdShellExposure.MANAGED
356    #: Under ``open`` exposure, the starting-credential shape — see
357    #: ``AwdOpenShellCredential``. ``owner_random`` mints a per-box
358    #: password given only to the owner (rivals must earn access);
359    #: ``fixed_default`` ships one default every team knows (rivals walk
360    #: in until it is rotated). Inert under ``managed`` exposure. Defaults
361    #: ``owner_random`` — the safer shape.
362    open_shell_credential: AwdOpenShellCredential = AwdOpenShellCredential.OWNER_RANDOM
363    #: When round 0 opens. ``None`` means "not scheduled yet", which is
364    #: what makes the whole configuration editable.
365    starts_at: AwareDatetime | None = None
366    #: ⚠️ Both floors are 1, not 0. `is_awd_match` reads a zero on
367    #: either as "not an AWD match", so storing one creates a row that
368    #: every other surface then denies exists — a match an organiser can
369    #: see in the form and nowhere else.
370    tick_seconds: int = Field(ge=1)
371    tick_count: int = Field(ge=1)
372    #: §16.10 — two independent caps, never a mode enum. Both stay
373    #: editable mid-match: `tick_budget_for` is read per round and is
374    #: purely forward-looking, and relaxing the allowance is exactly
375    #: what an organiser does after a bad round eats the field's.
376    per_tick_budget: int | None = Field(default=None, ge=0)
377    total_budget: int | None = Field(default=None, ge=0)

What an organiser may set on a match.

⚠️ last_tick_done is deliberately absent, and that absence is a control rather than an omission. It is the tick loop's bookmark — "has this round been settled" — and a body carrying it lets one save rewind the loop, which then re-probes SLA and re-settles rounds the board has already published and cached as final (the board's cache keys on the resolved round precisely because a settled round never changes again). The route preserves the stored value; nothing on the wire can move it.

created_at is absent for the smaller version of the same reason: it is a fact about the row, not a setting.

⚠️ extra="forbid", so sending one is a 422 rather than a silent drop. The route preserves the stored bookmark either way, so this buys nothing mechanically — it buys the client being told. A caller that believes it can rewind the loop and gets a 200 goes on believing it, and the next thing it writes may not be against a model that ignores the field.

challenge_id: str = ''
reference_victim: bool = False
scoring_params: dict[str, bool | float] = PydanticUndefined
attacker: ctfy.core.enums.AwdAttacker = <AwdAttacker.NONE: 'none'>
defence_mode: ctfy.core.enums.AwdDefenceMode = <AwdDefenceMode.PATCH: 'patch'>
shell_exposure: ctfy.core.enums.AwdShellExposure = <AwdShellExposure.MANAGED: 'managed'>
open_shell_credential: ctfy.core.enums.AwdOpenShellCredential = <AwdOpenShellCredential.OWNER_RANDOM: 'owner_random'>
starts_at: pydantic.types.AwareDatetime | None = None
tick_seconds: int = PydanticUndefined
tick_count: int = PydanticUndefined
per_tick_budget: int | None = None
total_budget: int | None = None
class AwdMatchInfo(ctfy.server.models.AwdMatchConfig):
422class AwdMatchInfo(AwdMatchConfig):
423    """A match as an organiser reads it: the settings plus the clock.
424
425    The three added fields are all **read-only by construction** —
426    ``current_tick`` is derived from the wall clock on every read (see
427    `ctfy.core.awd.current_tick`) and the other two are the loop's own
428    record. Extending the request model rather than declaring a second
429    one keeps the shared half from drifting between what a form submits
430    and what it renders back.
431    """
432
433    competition_id: str = ""
434    #: The round in progress, or ``None`` before the first and after the
435    #: last. Never stored, never settable.
436    current_tick: int | None = None
437    #: The last round settled. ``-1`` means none has been, which is
438    #: distinct from round 0 having completed.
439    last_tick_done: int = -1
440    #: ⚠️ Not ``current_tick is not None`` — that goes back to ``None``
441    #: when the match ends, and a finished match is still started for
442    #: every purpose the organiser's form cares about.
443    started: bool = False
444    created_at: AwareDatetime | None = None
445    #: ⚠️ Whether the ``awd_tick`` scheduled job is switched on. It ships
446    #: **disabled** — deliberately, since it starts containers on worker
447    #: nodes and a match still being configured must not begin turning on
448    #: its own — so the default state of a freshly created match is
449    #: "everything looks configured and no round will ever run".
450    #:
451    #: It is on the *match* rather than left to the client reading
452    #: ``GET /admin/scheduled-jobs``, because that route is
453    #: ``require_admin`` while this page is ``require_competition_admin``:
454    #: a per-competition organiser — exactly the person running the
455    #: event — would get a 403 and see no warning at all.
456    tick_job_enabled: bool = False
457    #: The ``llm_calls.challenge_id`` this match's attacker files its
458    #: model calls under, or ``""`` when it makes none. The organiser's
459    #: only door to *why* a round captured nothing:
460    #: `awd_attack.AttackRun` hands back an exit code, and all four of
461    #: the causes its docstring separates arrive at the board as one
462    #: empty answer map.
463    #:
464    #: ⚠️ **Not ``challenge_id``, which on this very model is the
465    #: gamebox.** Two unrelated namespaces would otherwise share a name
466    #: one field apart — a benchmark an organiser curated, and a
467    #: platform budget sentinel nobody chose.
468    #:
469    #: ⚠️ **The id, never the link.** A query string is presentation and
470    #: belongs to whoever draws it; this sentinel is platform identity a
471    #: browser has no other way to learn. Shipping the URL instead is
472    #: *copy belongs to the template, identity to the caller* inverted.
473    #:
474    #: ⚠️ **``principal=platform`` is not enough on its own**, and that
475    #: is the filter that writes itself: the report grader carries the
476    #: same principal, so a door keyed on it alone opens a page mixing
477    #: two platform callers' conversations. The challenge id is the
478    #: whole of what separates them — deliberately, since they are
479    #: separate budget rows.
480    #:
481    #: ⚠️ ``""`` is also what an older server sends, and that is the
482    #: quiet direction: a deployment that cannot answer draws no link
483    #: rather than one onto an empty page.
484    attacker_llm_challenge_id: str = ""
485    #: The pre-kickoff arena check and its override (#1713).
486    arena_gate: AwdArenaGate = Field(default_factory=AwdArenaGate)

A match as an organiser reads it: the settings plus the clock.

The three added fields are all read-only by construction — current_tick is derived from the wall clock on every read (see ctfy.core.awd.current_tick) and the other two are the loop's own record. Extending the request model rather than declaring a second one keeps the shared half from drifting between what a form submits and what it renders back.

competition_id: str = ''
current_tick: int | None = None
last_tick_done: int = -1
started: bool = False
created_at: pydantic.types.AwareDatetime | None = None
tick_job_enabled: bool = False
attacker_llm_challenge_id: str = ''
arena_gate: AwdArenaGate = PydanticUndefined
class AwdMatchStatus(ctfy.core.models.CtfyModel):
521class AwdMatchStatus(CtfyModel):
522    """The match's clock and rules, as a player reads them.
523
524    Public, like the board: the cadence *is* the format's rules, and a
525    team that cannot see which round it is in cannot play. Deliberately
526    a different model from `AwdMatchInfo` — that one is the organiser's
527    form and carries nothing a player needs, while this one carries
528    ``tick_ends_at``, which the form does not.
529
530    ⚠️ **`tick_ends_at` is on the wire rather than derived client-side.**
531    A countdown computed in the browser from ``starts_at`` +
532    ``tick_seconds`` is a second implementation of the round arithmetic
533    `ctfy.core.awd` exists to keep in one place — and the one that would
534    disagree is the one every player is watching.
535    """
536
537    competition_id: str = ""
538    challenge_id: str = ""
539    starts_at: AwareDatetime | None = None
540    tick_seconds: int = 0
541    tick_count: int = 0
542    #: The round in progress, or ``None`` before the first and after the
543    #: last. Never stored; recomputed on every read.
544    current_tick: int | None = None
545    #: When the round in progress ends. ``None`` when none is.
546    tick_ends_at: AwareDatetime | None = None
547    #: When the **last** round closes — the match's own end.
548    #:
549    # ⚠️ **On the wire for ``tick_ends_at``'s reason, and it answers a
550    # question no other field here can.** ``current_tick`` is ``None``
551    # before the first round *and* after the last, ``tick_ends_at`` is
552    # ``None`` in both, and ``last_tick_done`` is ``-1`` both before
553    # kickoff and after a match that settled nothing — so *is this
554    # match over* was unanswerable from this model, which is the first
555    # thing a post-match surface has to ask. The browser-side
556    # ``starts_at + tick_seconds * tick_count`` is one line and it is
557    # the second implementation of ``ctfy.core.awd``'s arithmetic that
558    # the field above exists to refuse.
559    #
560    # ⚠️ ``None`` is *this match cannot run* (no ``starts_at``), never
561    # *it has not ended*: an unscheduled match has no end, and an epoch
562    # there would read as one that finished in 1970.
563    ends_at: AwareDatetime | None = None
564    #: The last round settled — the one the board is showing. ``-1``
565    #: means none has been, which is not the same as round 0.
566    last_tick_done: int = -1
567    per_tick_budget: int | None = None
568    total_budget: int | None = None
569    #: How to defend — ``shell`` (fix the live box) or ``patch`` (submit
570    #: to the judge). Public like the cadence, because it *is* a rule of
571    #: the match: a defender who cannot see it does not know whether to
572    #: reach for the shell or ``POST /patches``. Defaults ``patch`` so an
573    #: older server reads as today's behaviour.
574    defence_mode: AwdDefenceMode = AwdDefenceMode.PATCH
575    #: Which SSH transport a ``shell``-mode box exposes — see
576    #: ``AwdShellExposure``. Public like ``defence_mode`` because it *is*
577    #: a rule of the match: under ``open`` a rival can reach every box's
578    #: ``sshd`` on the arena, and a defender must know to rotate their own
579    #: credential. Defaults ``managed`` so an older server reads as
580    #: today's controlled shell.
581    shell_exposure: AwdShellExposure = AwdShellExposure.MANAGED

The match's clock and rules, as a player reads them.

Public, like the board: the cadence is the format's rules, and a team that cannot see which round it is in cannot play. Deliberately a different model from AwdMatchInfo — that one is the organiser's form and carries nothing a player needs, while this one carries tick_ends_at, which the form does not.

⚠️ tick_ends_at is on the wire rather than derived client-side. A countdown computed in the browser from starts_at + tick_seconds is a second implementation of the round arithmetic ctfy.core.awd exists to keep in one place — and the one that would disagree is the one every player is watching.

competition_id: str = ''
challenge_id: str = ''
starts_at: pydantic.types.AwareDatetime | None = None
tick_seconds: int = 0
tick_count: int = 0
current_tick: int | None = None
tick_ends_at: pydantic.types.AwareDatetime | None = None
ends_at: pydantic.types.AwareDatetime | None = None
last_tick_done: int = -1
per_tick_budget: int | None = None
total_budget: int | None = None
defence_mode: ctfy.core.enums.AwdDefenceMode = <AwdDefenceMode.PATCH: 'patch'>
shell_exposure: ctfy.core.enums.AwdShellExposure = <AwdShellExposure.MANAGED: 'managed'>
class AwdProvisionPlanInfo(ctfy.core.models.CtfyModel):
489class AwdProvisionPlanInfo(CtfyModel):
490    """What opening the arena would do, before it does it.
491
492    A read, deliberately separate from the launch: §4.5 fixes the box
493    supply at kickoff and admits no late teams, so an organiser wants to
494    see "12 teams, 10 need a box" *before* committing — and the refusals
495    (no gamebox, no capacity, no healthy node) are exactly the ones that
496    must land where they can still be acted on.
497
498    ⚠️ **That last sentence was true of the launch and false of this
499    route, and it is recorded rather than quietly reworded because it is
500    the version somebody built on.** The route called `plan_provision`,
501    which knows four refusals; "no capacity" and "no healthy node" are
502    two of the several `launch_arena` raises on top of it, along with a
503    gamebox the catalog does not carry and a node with no WireGuard key
504    — the one that opens a clean, silently partitioned arena. All of
505    them came back **200 with counts**. The route walks `preflight_arena`
506    now, which is the launch's own preamble, so the sentence describes
507    the code instead of the intention.
508    """
509
510    #: Teams still needing a box, in a stable order —
511    #: `ProvisionPlan.to_start` says why the order is load-bearing.
512    to_start: list[str] = Field(default_factory=list)
513    #: Teams already holding one. Reported rather than dropped so a
514    #: resumed run can say what it skipped.
515    already: list[str] = Field(default_factory=list)
516    #: Every team in the match — the capacity figure §5.1 wants at build
517    #: time, not at kickoff.
518    total: int = 0

What opening the arena would do, before it does it.

A read, deliberately separate from the launch: §4.5 fixes the box supply at kickoff and admits no late teams, so an organiser wants to see "12 teams, 10 need a box" before committing — and the refusals (no gamebox, no capacity, no healthy node) are exactly the ones that must land where they can still be acted on.

⚠️ That last sentence was true of the launch and false of this route, and it is recorded rather than quietly reworded because it is the version somebody built on. The route called plan_provision, which knows four refusals; "no capacity" and "no healthy node" are two of the several launch_arena raises on top of it, along with a gamebox the catalog does not carry and a node with no WireGuard key — the one that opens a clean, silently partitioned arena. All of them came back 200 with counts. The route walks preflight_arena now, which is the launch's own preamble, so the sentence describes the code instead of the intention.

to_start: list[str] = PydanticUndefined
already: list[str] = PydanticUndefined
total: int = 0
class AwdRoundHistoryResponse(ctfy.core.models.CtfyModel):
615class AwdRoundHistoryResponse(CtfyModel):
616    """Every kept team's ladder, best final standing first.
617
618    ⚠️ ``last_tick == -1`` is a **legitimate empty history**, not an
619    error: between kickoff and the first settlement there is genuinely
620    nothing to show, and the page has to render then — the same reading
621    :class:`AwdBoardResponse` needs for its ``tick``.
622    """
623
624    last_tick: int = -1
625    series: list[AwdRoundSeries] = Field(default_factory=list)

Every kept team's ladder, best final standing first.

⚠️ last_tick == -1 is a legitimate empty history, not an error: between kickoff and the first settlement there is genuinely nothing to show, and the page has to render then — the same reading AwdBoardResponse needs for its tick.

last_tick: int = -1
series: list[AwdRoundSeries] = PydanticUndefined
class AwdRoundPoint(ctfy.core.models.CtfyModel):
584class AwdRoundPoint(CtfyModel):
585    """One team's standing after one settled round."""
586
587    tick: int = 0
588    #: Running total *through* this round — the stored cumulative value.
589    total: float = 0.0
590    attack: float = 0.0
591    defence: float = 0.0
592    sla: float = 0.0
593    #: ⚠️ Computed **per round**, over the teams that survive the
594    #: exclusion and the anonymity policy. A rank carried from the final
595    #: standing would draw parallel lines and hide every crossing, which
596    #: is the one thing a ladder exists to show.
597    rank: int = 0

One team's standing after one settled round.

tick: int = 0
total: float = 0.0
attack: float = 0.0
defence: float = 0.0
sla: float = 0.0
rank: int = 0
class AwdRoundSeries(ctfy.core.models.CtfyModel):
600class AwdRoundSeries(CtfyModel):
601    """One team's whole match."""
602
603    #: ⚠️ **Replaced by an alias for a withheld entrant**, not merely
604    #: accompanied by one. ``GET /teams`` is unauthenticated and answers
605    #: with every team's name against its id, so a real id here is the
606    #: alias undone by one request.
607    team_id: str = ""
608    team_name: str = ""
609    points: list[AwdRoundPoint] = Field(default_factory=list)
610    #: Whether this row wears an alias. Defaults ``False``: a client
611    #: reading a missing field as anonymous would tag the whole field.
612    anonymous: bool = False

One team's whole match.

team_id: str = ''
team_name: str = ''
points: list[AwdRoundPoint] = PydanticUndefined
anonymous: bool = False
class AwdSlaHistoryResponse(ctfy.core.models.CtfyModel):
1015class AwdSlaHistoryResponse(CtfyModel):
1016    """When each round's probes landed, newest round first.
1017
1018    ⚠️ **The offset is the product, not the timestamp.** A wall-clock
1019    instant makes every reader subtract a round start they have to derive
1020    themselves, and lets two of them derive it differently.
1021
1022    ⚠️ **`spread_s` is a pair, never a mean.** Every team in a round is
1023    probed in one pass, so what matters is how much later the last box
1024    was asked than the first — the only axis along which one entrant's
1025    round is longer than another's. A mean hides exactly that.
1026    """
1027
1028    competition_id: str = ""
1029    #: ⚠️ Published because an empty `rows` has two causes an operator
1030    #: acts on differently: a match that has not ticked, and a match that
1031    #: is not a match.
1032    is_match: bool = False
1033    rows: list[AwdSlaProbeRow] = Field(default_factory=list)
1034    #: ``(earliest, latest)`` offset across the rows, or ``None`` when
1035    #: nothing carries one.
1036    spread_s: tuple[float, float] | None = None

When each round's probes landed, newest round first.

⚠️ The offset is the product, not the timestamp. A wall-clock instant makes every reader subtract a round start they have to derive themselves, and lets two of them derive it differently.

⚠️ spread_s is a pair, never a mean. Every team in a round is probed in one pass, so what matters is how much later the last box was asked than the first — the only axis along which one entrant's round is longer than another's. A mean hides exactly that.

competition_id: str = ''
is_match: bool = False
rows: list[AwdSlaProbeRow] = PydanticUndefined
spread_s: tuple[float, float] | None = None
class AwdSlaProbeRow(ctfy.core.models.CtfyModel):
 993class AwdSlaProbeRow(CtfyModel):
 994    """One SLA probe, placed inside its own round."""
 995
 996    team_id: str = ""
 997    team_name: str = ""
 998    service_id: str = ""
 999    tick: int = 0
1000    #: `ctfy.core.awd.SlaStatus` value. ⚠️ Not narrowed to the enum: an
1001    #: older row can carry a status this server does not recognise, and a
1002    #: reader that refused the row would lose the reading rather than the
1003    #: label — `_read_status`' rule on the other side of the same string.
1004    status: str = ""
1005    #: Seconds from the round opening to the verdict being recorded.
1006    #:
1007    #: ⚠️ **``None`` is *not answerable*, never ``0.0``.** Zero is a real
1008    #: value — the probe landed on the round's opening second — and the
1009    #: two causes of an absent reading (a row written before the column
1010    #: existed, a match with no kickoff to measure from) both mean "no
1011    #: reading" to anybody looking.
1012    offset_s: float | None = None

One SLA probe, placed inside its own round.

team_id: str = ''
team_name: str = ''
service_id: str = ''
tick: int = 0
status: str = ''
offset_s: float | None = None
class AwdTarget(ctfy.core.models.CtfyModel):
1091class AwdTarget(CtfyModel):
1092    """One attackable service on one team's gamebox.
1093
1094    ⚠️ **The address is the whole point, and it exists nowhere else.** A
1095    gamebox declares no ``ports:`` (AWD008) and the match network is an
1096    ``internal`` bridge, so this address is the only way anything reaches
1097    the box. Until this model existed the platform computed it at launch,
1098    handed it to the node and forgot it — so no player, human or agent,
1099    could learn where a single rival was, and a classic match was
1100    unplayable by anyone for that one reason.
1101    """
1102
1103    #: Whose box this is. A foreign key, hence ``team_id`` rather than
1104    #: ``id`` — the entity here is the target, not the team.
1105    team_id: str = ""
1106    team_name: str = ""
1107    #: The ``awd.services`` entry, as the checker and the board name it.
1108    service_id: str = ""
1109    #: The box's address on the match network, e.g. ``10.7.2.5``.
1110    address: str = ""
1111    port: int = 0
1112    #: ⚠️ **The caller's own box is included and marked, never omitted.**
1113    #: An agent has to know which address is its own — to defend it, and
1114    #: because a flag taken from it is refused (``OWN_FLAG``) and a round
1115    #: spent discovering that is a round it did not attack anybody in.
1116    is_own: bool = False
1117    #: Whether ``team_id`` / ``team_name`` are an alias rather than this
1118    #: entrant's own. ⚠️ Default ``False``: an older server that does not
1119    #: know the field must not have every target read as withheld.
1120    anonymous: bool = False

One attackable service on one team's gamebox.

⚠️ The address is the whole point, and it exists nowhere else. A gamebox declares no ports: (AWD008) and the match network is an internal bridge, so this address is the only way anything reaches the box. Until this model existed the platform computed it at launch, handed it to the node and forgot it — so no player, human or agent, could learn where a single rival was, and a classic match was unplayable by anyone for that one reason.

team_id: str = ''
team_name: str = ''
service_id: str = ''
address: str = ''
port: int = 0
is_own: bool = False
anonymous: bool = False
class AwdTargetsResponse(ctfy.core.models.CtfyModel):
1123class AwdTargetsResponse(CtfyModel):
1124    """Every box a caller may attack this match, with its address.
1125
1126    ⚠️ **Only boxes that are up.** A team whose gamebox failed to start
1127    has no address to publish, and a row carrying ``""`` would send every
1128    attacker at an empty string — reported as a rival nobody could reach,
1129    which is exactly what a well-defended one looks like.
1130    """
1131
1132    competition_id: str = ""
1133    challenge_id: str = ""
1134    targets: list[AwdTarget] = Field(default_factory=list)

Every box a caller may attack this match, with its address.

⚠️ Only boxes that are up. A team whose gamebox failed to start has no address to publish, and a row carrying "" would send every attacker at an empty string — reported as a rival nobody could reach, which is exactly what a well-defended one looks like.

competition_id: str = ''
challenge_id: str = ''
targets: list[AwdTarget] = PydanticUndefined
class AwdVpnPeer(ctfy.core.models.CtfyModel):
679class AwdVpnPeer(CtfyModel):
680    """The peer this person holds on the arena, and how to use it.
681
682    ⚠️ **`config` is the deliverable, not the fields.** A player pastes
683    it into ``wg-quick`` and is done; publishing the parts and letting
684    every client compose its own file is three implementations of one
685    contract, and the one that gets a detail wrong produces a tunnel that
686    comes up and reaches nothing.
687    """
688
689    competition_id: str = ""
690    #: The address this peer holds on the arena.
691    address: str = ""
692    #: The node its tunnel terminates on — the one holding your own box.
693    node_id: str = ""
694    #: A complete ``wg-quick`` file.
695    #:
696    #: ⚠️ **The private half appears here once and is never stored.** On
697    #: a re-mint with no key of your own it is a *new* one: the old
698    #: config stops working, which is the honest behaviour for a rotation
699    #: and the reason the response says so rather than the client
700    #: guessing.
701    config: str = ""
702    #: Whether *config* carries a usable private key, or a placeholder
703    #: for the one you already hold. ⚠️ Never inferred from the text: a
704    #: client scanning for the placeholder is a second reader of a
705    #: format only `render_config` owns.
706    private_key_included: bool = False
707    #: What the last round's probe found, or ``None`` when no round has
708    #: asked yet. See :class:`AwdArenaLink` — absent is not down.
709    link: AwdArenaLink | None = None

The peer this person holds on the arena, and how to use it.

⚠️ config is the deliverable, not the fields. A player pastes it into wg-quick and is done; publishing the parts and letting every client compose its own file is three implementations of one contract, and the one that gets a detail wrong produces a tunnel that comes up and reaches nothing.

competition_id: str = ''
address: str = ''
node_id: str = ''
config: str = ''
private_key_included: bool = False
class AwdVpnRequest(ctfy.core.models.CtfyModel):
628class AwdVpnRequest(CtfyModel):
629    """Ask for a WireGuard peer on this match's arena.
630
631    ⚠️ **Both key-custody modes, and the field is what selects one.**
632    Supply *public_key* and the platform never sees your private half —
633    the response's config carries a placeholder you fill in. Leave it
634    empty and the platform mints a pair, hands the private half back
635    **once**, and keeps only the public one. Neither mode stores a
636    private key; there is no column for one.
637    """
638
639    #: Your own WireGuard public key, if you minted the pair yourself.
640    #: ``""`` asks the platform to mint one.
641    public_key: str = ""

Ask for a WireGuard peer on this match's arena.

⚠️ Both key-custody modes, and the field is what selects one. Supply public_key and the platform never sees your private half — the response's config carries a placeholder you fill in. Leave it empty and the platform mints a pair, hands the private half back once, and keeps only the public one. Neither mode stores a private key; there is no column for one.

public_key: str = ''
class AwdVpnStatus(ctfy.core.models.CtfyModel):
1065class AwdVpnStatus(CtfyModel):
1066    """Whether this person holds a peer on the arena right now.
1067
1068    ⚠️ **The read exists because ``POST`` cannot answer this without
1069    destroying the answer.** Minting is idempotent in its *address* and
1070    deliberately not in its *key*: a re-mint rotates, which is right for
1071    somebody who lost their file and exactly wrong for a page that
1072    reloads. Without a read, "you hold no peer" and "you hold one but I
1073    cannot ask" are the same blank, and the only way to tell them apart
1074    invalidates the config already on the laptop.
1075
1076    ⚠️ **An absent peer is a 200 with ``peer: None``, never a 404.** A
1077    competition this viewer may not see already answers 404 here on
1078    purpose, so that existence cannot be probed — so a 404 for "no peer"
1079    would be indistinguishable from "no such competition", and the two
1080    repairs are opposite.
1081
1082    ⚠️ **One field, not a ``held`` flag beside it.** Two shapes of one
1083    fact is what drifts, and the drifting direction here hands somebody
1084    state A while they hold a live peer — which is a button that rotates
1085    their key.
1086    """
1087
1088    peer: AwdVpnPeer | None = None

Whether this person holds a peer on the arena right now.

⚠️ The read exists because POST cannot answer this without destroying the answer. Minting is idempotent in its address and deliberately not in its key: a re-mint rotates, which is right for somebody who lost their file and exactly wrong for a page that reloads. Without a read, "you hold no peer" and "you hold one but I cannot ask" are the same blank, and the only way to tell them apart invalidates the config already on the laptop.

⚠️ An absent peer is a 200 with peer: None, never a 404. A competition this viewer may not see already answers 404 here on purpose, so that existence cannot be probed — so a 404 for "no peer" would be indistinguishable from "no such competition", and the two repairs are opposite.

⚠️ One field, not a held flag beside it. Two shapes of one fact is what drifts, and the drifting direction here hands somebody state A while they hold a live peer — which is a button that rotates their key.

peer: AwdVpnPeer | None = None
BigLimitOffsetPage = <class 'ctfy.server.models.pagination.LimitOffsetPageCustomized'>
BigLimitOffsetPageOf100 = <class 'ctfy.server.models.pagination.LimitOffsetPageCustomized'>
class BlueprintHostInfo(ctfy.core.models.CtfyModel):
64class BlueprintHostInfo(CtfyModel):
65    """One service in a range's estate, as an organiser sees it."""
66
67    name: str
68    segments: list[str] = Field(default_factory=list)
69    published: bool = False
70    #: Milestones whose value this service is handed.
71    milestones: list[str] = Field(default_factory=list)
72    #: Shares no network with anything published, so reaching it costs a
73    #: foothold. ⚠️ Defaults **False** — the claim a wrong value makes
74    #: matters here, and "reachable" is the one that merely costs a
75    #: second look, while a wrong "internal" tells an organiser a box is
76    #: protected when it is on the public segment.
77    behind_pivot: bool = False
78    #: ``estate`` | ``infrastructure``
79    role: HostRole = HostRole.ESTATE

One service in a range's estate, as an organiser sees it.

name: str = PydanticUndefined
segments: list[str] = PydanticUndefined
published: bool = False
milestones: list[str] = PydanticUndefined
behind_pivot: bool = False
role: ctfy.core.enums.HostRole = <HostRole.ESTATE: 'estate'>
class BlueprintMilestoneInfo(ctfy.core.models.CtfyModel):
 82class BlueprintMilestoneInfo(CtfyModel):
 83    """One milestone, placed in the estate and in the chain."""
 84
 85    id: str
 86    #: ``dynamic`` | ``static`` | ``seeded`` | ``single_select`` |
 87    #: ``multi_select`` | ``checker`` | ``unknown``
 88    mode: MilestoneMode = MilestoneMode.UNKNOWN
 89    requires: list[str] = Field(default_factory=list)
 90    #: Services carrying the value — **plural**, because a capability
 91    #: with several producers means several services legitimately hold a
 92    #: valid answer and naming one would invent a fact.
 93    hosts: list[str] = Field(default_factory=list)
 94    #: Which score capturing it feeds: ``capability`` or ``reporting``.
 95    #:
 96    #: ⚠️ **On the organiser's blueprint and on no player surface**, and
 97    #: the split is deliberate rather than an oversight — see
 98    #: ``BlueprintMilestone.scores``. Telling a player which milestones
 99    #: are cul-de-sac evidence hands over the triage judgement the
100    #: dead-end tier exists to measure; telling an *organiser* is the
101    #: only way they can see the chain apart from the wrong turns on a
102    #: range they did not author.
103    scores: str = "capability"
104    #: 1 for a milestone with no prerequisite; 0 means the ``requires``
105    #: graph has a cycle through this id.
106    depth: int = 1

One milestone, placed in the estate and in the chain.

id: str = PydanticUndefined
mode: ctfy.core.enums.MilestoneMode = <MilestoneMode.UNKNOWN: 'unknown'>
requires: list[str] = PydanticUndefined
hosts: list[str] = PydanticUndefined
scores: str = 'capability'
depth: int = 1
class BoardCriterion(ctfy.core.models.CtfyModel):
718class BoardCriterion(CtfyModel):
719    """One key a board ranks by, in precedence order.
720
721    A board shows a rank and a number. When two rows carry the same
722    number, what separated them was decided by the scoring rule's
723    tie-break and is invisible to everyone reading the page — including
724    the two teams it ranked. This says what the keys were.
725
726    ⚠️ **No value travels with it.** ``field`` names a
727    :class:`ScoreboardEntry` attribute, and the client reads the number
728    off the row it already holds — publishing the values again per row
729    would be a second copy that can disagree with the figures printed
730    beside them, which is the one failure a sort *explanation* must not
731    have.
732
733    ⚠️ **Derived from the rule's own ``order``, never described beside
734    it** — the criteria are what ``sort_key`` is built from, so this
735    cannot drift from the sort it explains. See
736    ``ctfy.server.scoring.base.SortCriterion``.
737    """
738
739    #: A ``ScoreboardEntry`` field name. ``test_the_ordering_is_derived``
740    #: checks every one against the model, because a name the model does
741    #: not carry reaches the browser as ``undefined`` and renders as a
742    #: blank cell beside a criterion claiming to have decided the rank.
743    field: str = ""
744    #: True when more is better. ⚠️ Not a "direction" string: the two
745    #: values are a closed set of exactly two, and the one place a reader
746    #: needs them is an arrow.
747    descending: bool = False

One key a board ranks by, in precedence order.

A board shows a rank and a number. When two rows carry the same number, what separated them was decided by the scoring rule's tie-break and is invisible to everyone reading the page — including the two teams it ranked. This says what the keys were.

⚠️ No value travels with it. field names a ScoreboardEntry attribute, and the client reads the number off the row it already holds — publishing the values again per row would be a second copy that can disagree with the figures printed beside them, which is the one failure a sort explanation must not have.

⚠️ Derived from the rule's own order, never described beside it — the criteria are what sort_key is built from, so this cannot drift from the sort it explains. See ctfy.server.scoring.base.SortCriterion.

field: str = ''
descending: bool = False
class BuildPushScopeRequest(ctfy.core.models.CtfyModel):
700class BuildPushScopeRequest(CtfyModel):
701    """What ``POST /admin/challenges/build-push`` freezes (Layer 2 P0).
702
703    An explicit scope is **required** — the plan freezes common families
704    first, manually, and defaulting to the whole ~2400-challenge corpus
705    would be an enormous unintended build campaign. Give a ``bucket``
706    (family directory such as ``XBOW``, matched case-insensitively) or an
707    explicit ``challenge_ids`` list, or both (their union). An empty
708    request is refused by the route.
709    """
710
711    #: Family / bucket directory to freeze — every challenge whose bucket
712    #: matches (e.g. ``XBOW``). ``""`` selects none by bucket.
713    bucket: str = ""
714    #: Explicit challenge ids to freeze, on top of any ``bucket`` match.
715    challenge_ids: list[str] = Field(default_factory=list)
716    #: Build host to run the freeze on. ``""`` picks the first online node
717    #: that runs each challenge; set it to freeze on one specific node
718    #: (e.g. the dedicated build host ``cb``).
719    node_id: str = ""

What POST /admin/challenges/build-push freezes (Layer 2 P0).

An explicit scope is required — the plan freezes common families first, manually, and defaulting to the whole ~2400-challenge corpus would be an enormous unintended build campaign. Give a bucket (family directory such as XBOW, matched case-insensitively) or an explicit challenge_ids list, or both (their union). An empty request is refused by the route.

bucket: str = ''
challenge_ids: list[str] = PydanticUndefined
node_id: str = ''
class BulkInviteRequest(ctfy.core.models.CtfyModel):
1455class BulkInviteRequest(CtfyModel):
1456    """Body for ``POST /admin/competitions/{id}/invites/bulk``.
1457
1458    Organisers arrive with a qualifying list — a column pasted out of a
1459    spreadsheet, newline- or comma-separated, usually with stray spaces
1460    and the odd duplicate. Splitting it server-side keeps every client
1461    (web, CLI, SDK) from having to agree on the same rules.
1462    """
1463
1464    # One blob rather than a parsed list: a textarea is what the organiser
1465    # actually has, and asking the client to split it invites three
1466    # slightly different splitters.
1467    emails: str = Field(default="", max_length=200_000)

Body for POST /admin/competitions/{id}/invites/bulk.

Organisers arrive with a qualifying list — a column pasted out of a spreadsheet, newline- or comma-separated, usually with stray spaces and the odd duplicate. Splitting it server-side keeps every client (web, CLI, SDK) from having to agree on the same rules.

emails: str = ''
class BulkInviteResponse(ctfy.core.models.CtfyModel):
1483class BulkInviteResponse(CtfyModel):
1484    """Per-address outcomes plus the tallies the toast reports.
1485
1486    Every address is echoed back rather than only the failures: an
1487    organiser pasting 300 lines needs to see that 300 were understood,
1488    and a silent drop is indistinguishable from success.
1489    """
1490
1491    results: list[BulkInviteResultRow] = Field(default_factory=list)
1492    invited: int = 0
1493    pending: int = 0
1494    already: int = 0
1495    invalid: int = 0

Per-address outcomes plus the tallies the toast reports.

Every address is echoed back rather than only the failures: an organiser pasting 300 lines needs to see that 300 were understood, and a silent drop is indistinguishable from success.

results: list[BulkInviteResultRow] = PydanticUndefined
invited: int = 0
pending: int = 0
already: int = 0
invalid: int = 0
class BulkInviteResultRow(ctfy.core.models.CtfyModel):
1470class BulkInviteResultRow(CtfyModel):
1471    """What happened to one address. ``status`` is one of:
1472
1473    ``invited`` — a new allowlist row (the user existed);
1474    ``pending`` — recorded against the address, waiting for a sign-up;
1475    ``already`` — invited before, so nothing changed;
1476    ``invalid`` — not an email address, skipped.
1477    """
1478
1479    email: str
1480    status: str

What happened to one address. status is one of:

invited — a new allowlist row (the user existed); pending — recorded against the address, waiting for a sign-up; already — invited before, so nothing changed; invalid — not an email address, skipped.

email: str = PydanticUndefined
status: str = PydanticUndefined
class CalendarBucket(ctfy.core.models.CtfyModel):
441class CalendarBucket(CtfyModel):
442    """One UTC-day cell on the contribution calendar.
443
444    ``count`` aggregates correct submissions + first-time solves on that
445    day; effectively "did anything productive happen". The series is
446    dense — every day in the requested window is present, zero-filled."""
447
448    date: str = ""  # YYYY-MM-DD, UTC
449    count: int = 0

One UTC-day cell on the contribution calendar.

count aggregates correct submissions + first-time solves on that day; effectively "did anything productive happen". The series is dense — every day in the requested window is present, zero-filled.

date: str = ''
count: int = 0
class ChallengeBlueprint(ctfy.core.models.CtfyModel):
109class ChallengeBlueprint(CtfyModel):
110    """A range's topology and kill chain, for the people running it.
111
112    ⚠️ **Derived, never carried.** Every field is read off the
113    challenge's own ``metadata.yaml`` and ``docker-compose.yml``, which
114    the deployment already has for every benchmark in the corpus. A
115    generator emitting a sidecar plan file would have covered only
116    generated ranges — leaving the hand-built ones, which are the reason
117    the format exists, with no view at all — and would have been a
118    second copy of facts the compose already carries, free to disagree
119    with the estate that actually boots.
120
121    ⚠️ **No groundtruth here.** This model says *what a milestone is and
122    where it lives*, never what it is worth. The value of a minted
123    answer is a per-instance fact and lives on the instance route, where
124    reading it is an audited act.
125    """
126
127    challenge_id: str
128    segments: list[str] = Field(default_factory=list)
129    hosts: list[BlueprintHostInfo] = Field(default_factory=list)
130    milestones: list[BlueprintMilestoneInfo] = Field(default_factory=list)
131    #: Milestones no service carries. ⚠️ Projected rather than dropped:
132    #: a page claiming an estate is fully mapped when a sixth of it is
133    #: not is worse than no page.
134    unplaced: list[str] = Field(default_factory=list)
135    #: Nothing is published — no ``ports:`` anywhere and no entry the
136    #: platform injects in their place — so ``behind_pivot`` has no
137    #: meaning and is False throughout. An engagement range whose entry
138    #: the platform injects is *not* this, whatever its compose says.
139    tunnelled: bool = False
140    #: Defects in the range's own metadata that no other surface reports
141    #: (a ``requires`` cycle, a ``requires`` naming nothing).
142    notes: list[str] = Field(default_factory=list)

A range's topology and kill chain, for the people running it.

⚠️ Derived, never carried. Every field is read off the challenge's own metadata.yaml and docker-compose.yml, which the deployment already has for every benchmark in the corpus. A generator emitting a sidecar plan file would have covered only generated ranges — leaving the hand-built ones, which are the reason the format exists, with no view at all — and would have been a second copy of facts the compose already carries, free to disagree with the estate that actually boots.

⚠️ No groundtruth here. This model says what a milestone is and where it lives, never what it is worth. The value of a minted answer is a per-instance fact and lives on the instance route, where reading it is an audited act.

challenge_id: str = PydanticUndefined
segments: list[str] = PydanticUndefined
hosts: list[BlueprintHostInfo] = PydanticUndefined
milestones: list[BlueprintMilestoneInfo] = PydanticUndefined
unplaced: list[str] = PydanticUndefined
tunnelled: bool = False
notes: list[str] = PydanticUndefined
class ChallengeBuildKickoffNodeResult(ctfy.core.models.CtfyModel):
722class ChallengeBuildKickoffNodeResult(CtfyModel):
723    """One node's response to a build kickoff (single or all).
724
725    ``queued`` is populated only on the ``build-all`` fan-out; for the
726    single-challenge variant the platform inspects ``status`` to learn
727    what the node accepted. ``""`` is *the node did not say* — the
728    bulk fan-out, or a node that could not be reached — which is a
729    different fact from any build status and so is its own member.
730    """
731
732    node_id: str
733    ok: bool
734    status: ChallengeBuildStatus | Literal[""] = ""
735    queued: list[str] = Field(default_factory=list)
736    skipped_built: list[str] = Field(default_factory=list)
737    skipped_in_progress: list[str] = Field(default_factory=list)
738    error: str = ""

One node's response to a build kickoff (single or all).

queued is populated only on the build-all fan-out; for the single-challenge variant the platform inspects status to learn what the node accepted. "" is the node did not say — the bulk fan-out, or a node that could not be reached — which is a different fact from any build status and so is its own member.

node_id: str = PydanticUndefined
ok: bool = PydanticUndefined
status: Union[ctfy.core.enums.ChallengeBuildStatus, Literal['']] = ''
queued: list[str] = PydanticUndefined
skipped_built: list[str] = PydanticUndefined
skipped_in_progress: list[str] = PydanticUndefined
error: str = ''
class ChallengeBuildKickoffResponse(ctfy.core.models.CtfyModel):
741class ChallengeBuildKickoffResponse(CtfyModel):
742    """Admin ``POST /admin/challenges/{id}/build`` and ``…/build-all`` response."""
743
744    challenge_id: str = ""  # empty for build-all
745    nodes: list[ChallengeBuildKickoffNodeResult] = Field(default_factory=list)

Admin POST /admin/challenges/{id}/build and …/build-all response.

challenge_id: str = ''
nodes: list[ChallengeBuildKickoffNodeResult] = PydanticUndefined
class ChallengeBuildNodeState(ctfy.core.models.CtfyModel):
658class ChallengeBuildNodeState(CtfyModel):
659    """One worker node's view of a single challenge's build state.
660
661    ``node_error`` is non-empty only when the platform could not reach
662    the node at all (heartbeat stale / 5xx) — in that case ``status`` is
663    forced to ``unbuilt`` for the aggregate.
664    """
665
666    node_id: str
667    status: ChallengeBuildStatus = ChallengeBuildStatus.UNBUILT
668    built_at: float = 0.0
669    error: str = ""
670    node_error: str = ""

One worker node's view of a single challenge's build state.

node_error is non-empty only when the platform could not reach the node at all (heartbeat stale / 5xx) — in that case status is forced to unbuilt for the aggregate.

node_id: str = PydanticUndefined
status: ctfy.core.enums.ChallengeBuildStatus = <ChallengeBuildStatus.UNBUILT: 'unbuilt'>
built_at: float = 0.0
error: str = ''
node_error: str = ''
class ChallengeBuildStateResponse(ctfy.core.models.CtfyModel):
694class ChallengeBuildStateResponse(CtfyModel):
695    """Admin ``GET /admin/challenges/build-state`` response."""
696
697    rows: list[ChallengeBuildStateRow] = Field(default_factory=list)

Admin GET /admin/challenges/build-state response.

rows: list[ChallengeBuildStateRow] = PydanticUndefined
class ChallengeBuildStateRow(ctfy.core.models.CtfyModel):
673class ChallengeBuildStateRow(CtfyModel):
674    """Per-challenge build state aggregated across every online node.
675
676    ``aggregated`` rolls up ``nodes`` using a worst-case rule so the
677    table's single status column behaves intuitively:
678
679    * any ``building`` → ``building`` (yellow)
680    * any ``failed``  → ``failed`` (red)
681    * any ``unbuilt`` → ``unbuilt`` (grey)
682    * else            → ``built`` (green)
683
684    A node that couldn't be reached at all contributes ``unbuilt`` so
685    the aggregate stays conservative — the admin still sees a
686    "missing" pip and can drill into the modal to find out why.
687    """
688
689    challenge_id: str
690    aggregated: ChallengeBuildStatus = ChallengeBuildStatus.UNBUILT
691    nodes: list[ChallengeBuildNodeState] = Field(default_factory=list)

Per-challenge build state aggregated across every online node.

aggregated rolls up nodes using a worst-case rule so the table's single status column behaves intuitively:

  • any building → building (yellow)
  • any failed → failed (red)
  • any unbuilt → unbuilt (grey)
  • else → built (green)

A node that couldn't be reached at all contributes unbuilt so the aggregate stays conservative — the admin still sees a "missing" pip and can drill into the modal to find out why.

challenge_id: str = PydanticUndefined
aggregated: ctfy.core.enums.ChallengeBuildStatus = <ChallengeBuildStatus.UNBUILT: 'unbuilt'>
nodes: list[ChallengeBuildNodeState] = PydanticUndefined
class ChallengeFacetCount(ctfy.core.models.CtfyModel):
569class ChallengeFacetCount(CtfyModel):
570    """One bucket on the challenge catalog facet summary.
571
572    ``value`` is the difficulty / tag string; ``total`` is how many
573    challenges in scope carry it. ``solved`` is the *calling* user's
574    solved count in that bucket (across every team they have been on);
575    it is 0 for anonymous callers and for tag buckets (the UI only
576    renders a solved/total ratio for difficulty).
577    """
578
579    value: str
580    total: int = 0
581    solved: int = 0

One bucket on the challenge catalog facet summary.

value is the difficulty / tag string; total is how many challenges in scope carry it. solved is the calling user's solved count in that bucket (across every team they have been on); it is 0 for anonymous callers and for tag buckets (the UI only renders a solved/total ratio for difficulty).

value: str = PydanticUndefined
total: int = 0
solved: int = 0
class ChallengeFacets(ctfy.core.models.CtfyModel):
584class ChallengeFacets(CtfyModel):
585    """Catalog aggregates for the Challenges page side panels + filter
586    pill counts — served by ``GET /challenges/facets`` so the page no
587    longer derives them from a bulk fetch of the whole catalog.
588
589    Scoped by ``competition_id`` (when given) exactly like the list
590    endpoint, so the pills/charts reflect the same subset the paged
591    list pages through.
592    """
593
594    total: int = 0
595    difficulty: list[ChallengeFacetCount] = Field(default_factory=list)
596    # Per-category counts (only categories with ≥1 challenge in scope
597    # show up). Ordered by the canonical category sequence so the
598    # filter pills always render web → pwn → reverse → crypto → misc.
599    category: list[ChallengeFacetCount] = Field(default_factory=list)
600    tags: list[ChallengeFacetCount] = Field(default_factory=list)
601    # Per-status counts for the calling caller (``solved`` / ``unsolved``
602    # / ``running``). Drives the count chip on each status filter pill
603    # so the page doesn't N+1 a status-filtered list per pill. Buckets
604    # are always present (in canonical order) for layout stability; on
605    # anonymous callers every count is 0 — never leaking another
606    # team's state.
607    status: list[ChallengeFacetCount] = Field(default_factory=list)
608    # Per-source-bucket counts — the prefix of a challenge id
609    # (``CVE-2021-1234`` → ``CVE``), i.e. the benchmark suite an item
610    # was ingested from. Ordered by count descending then id, so a
611    # caller rendering a top-N breakdown gets a stable, meaningful
612    # slice order without re-sorting. ``solved`` is always 0 here:
613    # this facet answers "what is the catalog made of", not "how am I
614    # doing", and the landing page that consumes it is anonymous.
615    buckets: list[ChallengeFacetCount] = Field(default_factory=list)

Catalog aggregates for the Challenges page side panels + filter pill counts — served by GET /challenges/facets so the page no longer derives them from a bulk fetch of the whole catalog.

Scoped by competition_id (when given) exactly like the list endpoint, so the pills/charts reflect the same subset the paged list pages through.

total: int = 0
difficulty: list[ChallengeFacetCount] = PydanticUndefined
category: list[ChallengeFacetCount] = PydanticUndefined
tags: list[ChallengeFacetCount] = PydanticUndefined
status: list[ChallengeFacetCount] = PydanticUndefined
buckets: list[ChallengeFacetCount] = PydanticUndefined
class ChallengeFlagStats(ctfy.core.models.CtfyModel):
816class ChallengeFlagStats(CtfyModel):
817    """Per-flag aggregate for one challenge (for the detail page)."""
818
819    flag_id: str
820    solves_count: int = 0

Per-flag aggregate for one challenge (for the detail page).

flag_id: str = PydanticUndefined
solves_count: int = 0
class ChallengeInfo(ctfy.core.models.CtfyModel):
419class ChallengeInfo(CtfyModel):
420    id: str
421    name: str
422    category: ChallengeCategory = ChallengeCategory.WEB
423    difficulty: str = ""
424    description: str = ""
425    # Full-challenge solves: teams that captured every declared
426    # question (across all modes — dynamic + static + select).
427    solves_count: int = 0
428    # Times this challenge has been instantiated, ever — one per
429    # archived InstanceRecord (every terminal path writes one). A
430    # "how much has this been run" signal for the challenge card;
431    # platform-wide, not scoped to the viewing team or competition.
432    launch_count: int = 0
433    tags: list[str] = Field(default_factory=list)
434    # Every question declared on this challenge, in author-declared
435    # order. Single-question challenges have a single entry with
436    # id ``"flag"``. ``QuestionPublicInfo`` exposes prompt/mode/choices
437    # but never the answer.
438    questions: list[QuestionPublicInfo] = Field(default_factory=list)
439    # Per-question solve counts (how many teams captured each
440    # question). Keyed by question id. Useful for the challenge
441    # detail page's progress breakdown. Counts span every mode;
442    # the multi_select grader records one solve per fully-correct
443    # submission, not per chosen choice.
444    flag_solves: dict[str, int] = Field(default_factory=dict)
445    # Files shipped under the challenge's ``attachments/`` directory,
446    # downloadable via ``GET /challenges/{id}/attachments/{name}``.
447    # Empty for pure-network challenges that ship nothing.
448    attachments: list[AttachmentInfo] = Field(default_factory=list)
449    # True for pure question-answer challenges (``category: misc``,
450    # no ``docker-compose.yml`` on disk, every declared question
451    # carries a static ``answer:``). The frontend keys off this flag
452    # to switch the competition surface to a quiz UI and skip the
453    # launch-confirm flow. Source of truth lives in
454    # :attr:`ctfy.core.challenge.ChallengeSpec.is_qa_only`, ultimately
455    # derived from :attr:`BenchmarkContext.is_qa_only`.
456    is_qa_only: bool = False
457    # Set for AWD+ challenges; ``None`` for every other kind. Same role
458    # as ``is_qa_only`` one field up — the flag a client keys off to
459    # switch surface — but an object rather than a bool because a
460    # defence surface needs to know *what* it may send, not only that it
461    # may send something.
462    defence: DefenceInfo | None = None
463    # ---- viewer-scoped: how far in is the caller -----------------------
464    #
465    # Populated only for an authenticated request; a guest gets the
466    # empty/False sentinels, which is what the server can honestly say
467    # about somebody it never looked up. Scoped by ``competition_id``
468    # exactly like the tallies above — unscoped they span every team the
469    # caller has been on, which is the standalone catalog's own
470    # platform-wide question and the wrong answer to "have I solved it
471    # *here*".
472    #
473    # ⚠️ **Two subjects, because the platform has two and they disagree
474    # on any roster with more than one human.** Every scoring surface
475    # here means the *team*: ``solves_count`` groups by ``team_id``,
476    # ``InstanceQuestionInfo.answered_correctly`` reads
477    # ``solved_flag_ids(team_id, …)``, ``QaChallengeView`` reads
478    # ``list_solves(team_id=…)``. ``/me/solves`` means the *person*
479    # (``Solve.user_id``, stamped at submit time), which is right for a
480    # profile page and was what the challenge card had been reading — so
481    # a team-mate who had not personally submitted saw 未解出 on a
482    # challenge their team already owned and could not re-earn.
483    #
484    # ⚠️ **A badge should render ``my_team_*``.** That is the subject the
485    # board, the attempt cap and first blood all use; ``my_*`` is the
486    # personal contribution split, and reading it as "solved" is the bug
487    # these four fields were added to end.
488    #
489    # ⚠️ Empty under a silent exercise, never a partial truth: §4b's
490    # channel 2 is the player's own verdict, and ``own_progress_hidden``
491    # is the gate. The id lists are subsets of what the caller's team
492    # captured, so they can only name questions that team has already
493    # unlocked — see ``projections.viewer_progress``.
494    #: Question ids **this person** captured. ⊆ ``my_team_solved_question_ids``.
495    my_solved_question_ids: list[str] = Field(default_factory=list)
496    #: Whether this person alone captured every declared question.
497    my_fully_solved: bool = False
498    #: Question ids **the caller's team** captured — the scoring subject.
499    my_team_solved_question_ids: list[str] = Field(default_factory=list)
500    #: Whether the caller's team captured every declared question. This
501    #: is the one a "solved" badge means.
502    my_team_fully_solved: bool = False
503    #: Whether the caller's team wrote this challenge — in a
504    #: peer-authoring event, whether solving it can pay them anything.
505    #:
506    #: ⚠️ **The fact existed and reached the player only after the
507    #: work.** `record_correct_answer` writes no solve row for an
508    #: author, and `SubmissionResponse.unranked_reason` says why — on
509    #: the *submission*, which is to say after they opened an instance,
510    #: solved the challenge and pressed submit. Measured on the real
511    #: route: an author's own upload and a stranger's came back from
512    #: `GET /competitions/{id}/challenges` with identical fields and
513    #: nothing about authorship anywhere. `unranked_reason`'s own
514    #: comment already called that moment a dead end — the copy under
515    #: ``own_authored`` is "advice that can never pay off".
516    #:
517    #: ⚠️ **``my_team_*``, deliberately, for its neighbours' reason.**
518    #: The board's subject is the team, so the question this answers is
519    #: *can my team score on this* — not *did I personally upload it*,
520    #: which would leave a captain's team-mates unwarned about a
521    #: challenge their own squad wrote.
522    #:
523    #: ⚠️ **Marks, never hides.** An author must be able to launch and
524    #: submit — it is the only way to check from outside that the
525    #: challenge they uploaded is answerable at all, which is
526    #: `authored_scoring`'s stated requirement rather than a
527    #: concession. Filtering these out of the listing would make the
528    #: one entrant who can fix a broken challenge the one who cannot
529    #: reach it.
530    #:
531    #: ⚠️ Defaults **False**, the quiet direction: a producer that did
532    #: not ask, or an older server, must not tell a team a challenge is
533    #: theirs and cost them one they could have scored on. The other
534    #: default's failure is silent and expensive; this one's is the
535    #: state before this field existed.
536    my_team_authored: bool = False
537    #: Who wrote this challenge, for a player-uploaded one whose author
538    #: asked to be named (plan P2.5) — their display name, never an
539    #: address — and ``""`` for everything else: the corpus, an upload
540    #: credited anonymously, an author with no display name, one whose
541    #: entry in the event they uploaded to is withheld from this viewer,
542    #: and one a competition this viewer can see is showing while it is
543    #: still live (#1927 — the name arrives when the last one ends).
544    #:
545    #: ⚠️ **Written by one function and nowhere else**
546    #: (`ctfy.server.anonymity.anonymise_author_credits`), and the
547    #: projection leaves it blank — so a route that forgets to ask names
548    #: nobody, the direction that fails closed. Every upload comes from an
549    #: entrant, so a name here also says *they entered that event*, and
550    #: that event's anonymity policy decides it before the grant does.
551    author_display_name: str = ""
552    #: The same author's account id, set exactly where the name is and by
553    #: the same function — what the detail page links to their profile's
554    #: list of uploads with. ``""`` wherever the name is.
555    author_user_id: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
name: str = PydanticUndefined
category: ctfy.challenge.benchmark.ChallengeCategory = <ChallengeCategory.WEB: 'web'>
difficulty: str = ''
description: str = ''
solves_count: int = 0
launch_count: int = 0
tags: list[str] = PydanticUndefined
questions: list[QuestionPublicInfo] = PydanticUndefined
flag_solves: dict[str, int] = PydanticUndefined
attachments: list[ctfy.core.target.AttachmentInfo] = PydanticUndefined
is_qa_only: bool = False
defence: DefenceInfo | None = None
my_solved_question_ids: list[str] = PydanticUndefined
my_fully_solved: bool = False
my_team_solved_question_ids: list[str] = PydanticUndefined
my_team_fully_solved: bool = False
my_team_authored: bool = False
author_display_name: str = ''
author_user_id: str = ''
class ChallengePullKickoffNodeResult(ctfy.core.models.CtfyModel):
793class ChallengePullKickoffNodeResult(CtfyModel):
794    """One node's response to a pull kickoff (single or all).
795
796    ``status`` reads the same way as the build-side twin: ``""`` is *the
797    node did not say*, not a pull status.
798    """
799
800    node_id: str
801    ok: bool
802    status: ChallengePullStatus | Literal[""] = ""
803    queued: list[str] = Field(default_factory=list)
804    skipped_pulled: list[str] = Field(default_factory=list)
805    skipped_in_progress: list[str] = Field(default_factory=list)
806    error: str = ""

One node's response to a pull kickoff (single or all).

status reads the same way as the build-side twin: "" is the node did not say, not a pull status.

node_id: str = PydanticUndefined
ok: bool = PydanticUndefined
status: Union[ctfy.core.enums.ChallengePullStatus, Literal['']] = ''
queued: list[str] = PydanticUndefined
skipped_pulled: list[str] = PydanticUndefined
skipped_in_progress: list[str] = PydanticUndefined
error: str = ''
class ChallengePullKickoffResponse(ctfy.core.models.CtfyModel):
809class ChallengePullKickoffResponse(CtfyModel):
810    """Admin ``POST /admin/challenges/{id}/pull`` and ``…/pull-all`` response."""
811
812    challenge_id: str = ""  # empty for pull-all
813    nodes: list[ChallengePullKickoffNodeResult] = Field(default_factory=list)

Admin POST /admin/challenges/{id}/pull and …/pull-all response.

challenge_id: str = ''
nodes: list[ChallengePullKickoffNodeResult] = PydanticUndefined
class ChallengePullNodeState(ctfy.core.models.CtfyModel):
755class ChallengePullNodeState(CtfyModel):
756    """One worker node's view of a single challenge's pull state.
757
758    ``node_error`` is non-empty only when the platform could not reach
759    the node at all — ``status`` is then forced to ``unpulled`` for the
760    aggregate.
761    """
762
763    node_id: str
764    status: ChallengePullStatus = ChallengePullStatus.UNPULLED
765    pulled_at: float = 0.0
766    error: str = ""
767    node_error: str = ""

One worker node's view of a single challenge's pull state.

node_error is non-empty only when the platform could not reach the node at all — status is then forced to unpulled for the aggregate.

node_id: str = PydanticUndefined
status: ctfy.core.enums.ChallengePullStatus = <ChallengePullStatus.UNPULLED: 'unpulled'>
pulled_at: float = 0.0
error: str = ''
node_error: str = ''
class ChallengePullStateResponse(ctfy.core.models.CtfyModel):
787class ChallengePullStateResponse(CtfyModel):
788    """Admin ``GET /admin/challenges/pull-state`` response."""
789
790    rows: list[ChallengePullStateRow] = Field(default_factory=list)

Admin GET /admin/challenges/pull-state response.

rows: list[ChallengePullStateRow] = PydanticUndefined
class ChallengePullStateRow(ctfy.core.models.CtfyModel):
770class ChallengePullStateRow(CtfyModel):
771    """Per-challenge pull state aggregated across every online node.
772
773    ``aggregated`` rolls up ``nodes`` worst-case, mirroring the build
774    row:
775
776    * any ``pulling`` → ``pulling`` (yellow)
777    * any ``failed``  → ``failed`` (red)
778    * any ``unpulled`` → ``unpulled`` (grey)
779    * else            → ``pulled`` (green)
780    """
781
782    challenge_id: str
783    aggregated: ChallengePullStatus = ChallengePullStatus.UNPULLED
784    nodes: list[ChallengePullNodeState] = Field(default_factory=list)

Per-challenge pull state aggregated across every online node.

aggregated rolls up nodes worst-case, mirroring the build row:

  • any pulling → pulling (yellow)
  • any failed → failed (red)
  • any unpulled → unpulled (grey)
  • else → pulled (green)
challenge_id: str = PydanticUndefined
aggregated: ctfy.core.enums.ChallengePullStatus = <ChallengePullStatus.UNPULLED: 'unpulled'>
nodes: list[ChallengePullNodeState] = PydanticUndefined
class ChallengeRescanNodeResult(ctfy.core.models.CtfyModel):
618class ChallengeRescanNodeResult(CtfyModel):
619    """Outcome of fanning the rescan out to one worker node.
620
621    ``ok`` is False for an offline node (skipped, never contacted) or a
622    node that errored; ``total`` is the node's post-rescan challenge
623    count when reached, else ``None``. One unreachable node never fails
624    the whole operation — the platform rescan still stands.
625    """
626
627    node_id: str
628    ok: bool
629    total: int | None = None
630    # ⚠️ ``""``, not ``None``. This is the producer of the ``null`` that
631    # ten durable task rows carry and that ``TaskNodeLine`` then refused
632    # — one 500 for the whole admin task list. Absent and empty assert
633    # the same thing for an error string, unlike ``total`` above where
634    # ``None`` means "this producer has no such concept".
635    error: str = ""

Outcome of fanning the rescan out to one worker node.

ok is False for an offline node (skipped, never contacted) or a node that errored; total is the node's post-rescan challenge count when reached, else None. One unreachable node never fails the whole operation — the platform rescan still stands.

node_id: str = PydanticUndefined
ok: bool = PydanticUndefined
total: int | None = None
error: str = ''
class ChallengeRescanResult(ctfy.core.models.CtfyModel):
638class ChallengeRescanResult(CtfyModel):
639    """Admin ``POST /admin/challenges/rescan`` response.
640
641    ``total`` / ``added`` / ``removed`` describe the platform's own
642    catalog after re-scanning ``challenges_dir``; ``nodes`` carries the
643    per-worker fan-out outcome so a newly added challenge is confirmed
644    launchable, not just listable.
645    """
646
647    total: int
648    added: list[str] = Field(default_factory=list)
649    removed: list[str] = Field(default_factory=list)
650    nodes: list[ChallengeRescanNodeResult] = Field(default_factory=list)

Admin POST /admin/challenges/rescan response.

total / added / removed describe the platform's own catalog after re-scanning challenges_dir; nodes carries the per-worker fan-out outcome so a newly added challenge is confirmed launchable, not just listable.

total: int = PydanticUndefined
added: list[str] = PydanticUndefined
removed: list[str] = PydanticUndefined
nodes: list[ChallengeRescanNodeResult] = PydanticUndefined
class ChallengeSolveAttempt(ctfy.core.models.CtfyModel):
837class ChallengeSolveAttempt(CtfyModel):
838    """One archived instance for a challenge — used to visualise per-team
839    multi-solve attempts on the challenge detail page."""
840
841    instance_id: str
842    team_id: str = ""
843    team_name: str = ""
844    display_name: str = ""
845    challenge_id: str = ""
846    started_at: float = 0.0
847    stopped_at: float = 0.0
848    duration_s: float = 0.0
849    attempts: int = 0
850    solved: bool = False
851    solved_flags: list[str] = Field(default_factory=list)
852    stop_reason: str = ""
853    #: See :attr:`ScoreboardEntry.anonymous`.
854    anonymous: bool = False

One archived instance for a challenge — used to visualise per-team multi-solve attempts on the challenge detail page.

instance_id: str = PydanticUndefined
team_id: str = ''
team_name: str = ''
display_name: str = ''
challenge_id: str = ''
started_at: float = 0.0
stopped_at: float = 0.0
duration_s: float = 0.0
attempts: int = 0
solved: bool = False
solved_flags: list[str] = PydanticUndefined
stop_reason: str = ''
anonymous: bool = False
class ChallengeSolveAttemptsResponse(ctfy.core.models.CtfyModel):
873class ChallengeSolveAttemptsResponse(CtfyModel):
874    challenge_id: str
875    attempts: list[ChallengeSolveAttempt] = Field(default_factory=list)
876    per_team: list[ChallengeTeamSolveSummary] = Field(default_factory=list)
877    # Total archived instances (incl. unsolved) — lets the UI show
878    # "showing N solved of M total" when the response is filtered.
879    total_attempts: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

challenge_id: str = PydanticUndefined
attempts: list[ChallengeSolveAttempt] = PydanticUndefined
per_team: list[ChallengeTeamSolveSummary] = PydanticUndefined
total_attempts: int = 0
class ChallengeStats(ctfy.core.models.CtfyModel):
823class ChallengeStats(CtfyModel):
824    challenge_id: str
825    name: str = ""
826    category: ChallengeCategory | None = None
827    difficulty: str = ""
828    # Teams that captured every declared flag on this challenge.
829    solves_count: int = 0
830    total_attempts: int = 0
831    success_rate: float = 0.0
832    # Per-flag breakdown. Single-flag challenges have a single entry with
833    # ``flag_id == "flag"`` and ``solves_count == solves_count`` above.
834    flag_stats: list[ChallengeFlagStats] = Field(default_factory=list)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

challenge_id: str = PydanticUndefined
name: str = ''
category: ctfy.challenge.benchmark.ChallengeCategory | None = None
difficulty: str = ''
solves_count: int = 0
total_attempts: int = 0
success_rate: float = 0.0
flag_stats: list[ChallengeFlagStats] = PydanticUndefined
class ChallengeTeamSolveSummary(ctfy.core.models.CtfyModel):
857class ChallengeTeamSolveSummary(CtfyModel):
858    """Per-team aggregate for the challenge detail solve-attempts panel."""
859
860    team_id: str
861    team_name: str = ""
862    display_name: str = ""
863    solve_count: int = 0
864    attempt_count: int = 0
865    best_duration_s: float = 0.0
866    total_duration_s: float = 0.0
867    #: See :attr:`ScoreboardEntry.anonymous`.
868    anonymous: bool = False
869    first_solved_at: float = 0.0
870    last_solved_at: float = 0.0

Per-team aggregate for the challenge detail solve-attempts panel.

team_id: str = PydanticUndefined
team_name: str = ''
display_name: str = ''
solve_count: int = 0
attempt_count: int = 0
best_duration_s: float = 0.0
total_duration_s: float = 0.0
anonymous: bool = False
first_solved_at: float = 0.0
last_solved_at: float = 0.0
class ChallengeValuePoint(ctfy.core.models.CtfyModel):
1272class ChallengeValuePoint(CtfyModel):
1273    """One point on a challenge's decay curve.
1274
1275    ⚠️ ``solvers`` is **1-based**, matching the decay arithmetic
1276    everywhere else: the first team to capture sees the full base value
1277    under every curve, which is the property that makes the curves
1278    comparable to each other and to a static board.
1279    """
1280
1281    solvers: int = 0
1282    value: int = 0

One point on a challenge's decay curve.

⚠️ solvers is 1-based, matching the decay arithmetic everywhere else: the first team to capture sees the full base value under every curve, which is the property that makes the curves comparable to each other and to a static board.

solvers: int = 0
value: int = 0
class ChangelogAnnounceRequest(ctfy.core.models.CtfyModel):
142class ChangelogAnnounceRequest(CtfyModel):
143    """Body for ``POST /admin/changelog/{id}/announce``.
144
145    ``severity`` rides through to the announcement the broadcast creates,
146    which is what decides whether it also becomes email
147    (``notify_announcement`` mails ``warning`` and ``critical`` only).
148    ⚠️ Default ``info`` — a release note is worth a bell, not an inbox,
149    and the louder default would put one message per user in the outbox
150    for every entry ever published.
151    """
152
153    # ⚠️ **It was `str`, and that left two things behind.** The route
154    # hand-rolled its own membership test — a fourth copy of
155    # `AnnouncementSeverity`, in a module with nothing else to say about
156    # severity — and the wire published the field as a bare `string`
157    # while its sibling `AnnouncementCreate` published the union, so
158    # `api.ts` was *already* typing the argument `AnnouncementSeverity`:
159    # the client asserted more than the server promised, about the value
160    # that decides who gets mail.
161    #
162    # ⚠️ A comment rather than more docstring: a model's docstring is
163    # copied into the OpenAPI `description` and from there into
164    # `frontend/src/generated/api.ts`, so this paragraph would ship to
165    # every reader of the published schema.
166    severity: AnnouncementSeverity = "info"

Body for POST /admin/changelog/{id}/announce.

severity rides through to the announcement the broadcast creates, which is what decides whether it also becomes email (notify_announcement mails warning and critical only). ⚠️ Default info — a release note is worth a bell, not an inbox, and the louder default would put one message per user in the outbox for every entry ever published.

severity: Literal['info', 'warning', 'critical'] = 'info'
class ChangelogAnnounceResponse(ctfy.core.models.CtfyModel):
169class ChangelogAnnounceResponse(CtfyModel):
170    """What the broadcast actually did, in the units an operator can
171    check: one Inbox row per active user, and the mail it queued.
172
173    ⚠️ ``queued`` is what the **outbox accepted**, not the number of
174    users — preferences and suppressions cut it, and reporting the
175    audience size would tell an operator mail went out to people it never
176    reached."""
177
178    entry: AdminChangelogEntryInfo
179    announcement_id: str = ""
180    recipients: int = 0
181    queued: int = 0

What the broadcast actually did, in the units an operator can check: one Inbox row per active user, and the mail it queued.

⚠️ queued is what the outbox accepted, not the number of users — preferences and suppressions cut it, and reporting the audience size would tell an operator mail went out to people it never reached.

entry: AdminChangelogEntryInfo = PydanticUndefined
announcement_id: str = ''
recipients: int = 0
queued: int = 0
class ChangelogEntryCreate(ctfy.core.models.CtfyModel):
105class ChangelogEntryCreate(CtfyModel):
106    """Request body for ``POST /admin/changelog``.
107
108    ``published`` is a bool rather than a timestamp: the moment is the
109    server's, so a client cannot backdate an entry into the middle of a
110    list somebody has already read.
111    """
112
113    slug: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9][a-z0-9-]*$")
114    category: ChangelogCategory = "feature"
115    version: str = Field(default="", max_length=32)
116    title: dict[str, str] = Field(default_factory=dict)
117    summary: dict[str, str] = Field(default_factory=dict)
118    body: dict[str, str] = Field(default_factory=dict)
119    emoji: str = Field(default="", max_length=8)
120    published: bool = False

Request body for POST /admin/changelog.

published is a bool rather than a timestamp: the moment is the server's, so a client cannot backdate an entry into the middle of a list somebody has already read.

slug: str = PydanticUndefined
category: Literal['feature', 'improvement', 'fix'] = 'feature'
version: str = ''
title: dict[str, str] = PydanticUndefined
summary: dict[str, str] = PydanticUndefined
body: dict[str, str] = PydanticUndefined
emoji: str = ''
published: bool = False
class ChangelogEntryInfo(ctfy.core.models.CtfyModel):
36class ChangelogEntryInfo(CtfyModel):
37    """One published change, as a visitor sees it."""
38
39    id: str
40    slug: str
41    category: ChangelogCategory
42    version: str = ""
43    title: dict[str, str] = Field(default_factory=dict)
44    summary: dict[str, str] = Field(default_factory=dict)
45    body: dict[str, str] = Field(default_factory=dict)
46    emoji: str = ""
47    published_at: datetime | None = None

One published change, as a visitor sees it.

id: str = PydanticUndefined
slug: str = PydanticUndefined
category: Literal['feature', 'improvement', 'fix'] = PydanticUndefined
version: str = ''
title: dict[str, str] = PydanticUndefined
summary: dict[str, str] = PydanticUndefined
body: dict[str, str] = PydanticUndefined
emoji: str = ''
published_at: datetime.datetime | None = None
class ChangelogEntryUpdate(ctfy.core.models.CtfyModel):
123class ChangelogEntryUpdate(CtfyModel):
124    """PATCH body — every field optional, ``None`` means "leave alone".
125
126    ⚠️ Read through ``model_fields_set``, never field-by-field against
127    ``None``: the organiser's edit form sends one field at a time, and
128    treating an absent key as an empty value blanks the other five. The
129    same shape the registration edit route already has to get right.
130    """
131
132    slug: str | None = Field(default=None, min_length=1, pattern=r"^[a-z0-9][a-z0-9-]*$")
133    category: ChangelogCategory | None = None
134    version: str | None = Field(default=None, max_length=32)
135    title: dict[str, str] | None = None
136    summary: dict[str, str] | None = None
137    body: dict[str, str] | None = None
138    emoji: str | None = Field(default=None, max_length=8)
139    published: bool | None = None

PATCH body — every field optional, None means "leave alone".

⚠️ Read through model_fields_set, never field-by-field against None: the organiser's edit form sends one field at a time, and treating an absent key as an empty value blanks the other five. The same shape the registration edit route already has to get right.

slug: str | None = None
category: Optional[Literal['feature', 'improvement', 'fix']] = None
version: str | None = None
title: dict[str, str] | None = None
summary: dict[str, str] | None = None
body: dict[str, str] | None = None
emoji: str | None = None
published: bool | None = None
class ChangelogImportEntry(ctfy.core.models.CtfyModel):
184class ChangelogImportEntry(CtfyModel):
185    """One parsed ``docs/changelog/*.md`` file on its way in.
186
187    ⚠️ ``date`` is a plain ``YYYY-MM-DD`` string rather than a datetime:
188    it is what the author typed in frontmatter, and a release is dated to
189    a day. Widening it to a timestamp invites a timezone question the
190    file cannot answer.
191    """
192
193    slug: str = Field(min_length=1, max_length=120, pattern=r"^[a-z0-9][a-z0-9-]*$")
194    category: ChangelogCategory = "feature"
195    version: str = Field(default="", max_length=32)
196    title: dict[str, str] = Field(default_factory=dict)
197    summary: dict[str, str] = Field(default_factory=dict)
198    body: dict[str, str] = Field(default_factory=dict)
199    emoji: str = Field(default="", max_length=8)
200    date: str = Field(default="", pattern=r"^(\d{4}-\d{2}-\d{2})?$")

One parsed docs/changelog/*.md file on its way in.

⚠️ date is a plain YYYY-MM-DD string rather than a datetime: it is what the author typed in frontmatter, and a release is dated to a day. Widening it to a timestamp invites a timezone question the file cannot answer.

slug: str = PydanticUndefined
category: Literal['feature', 'improvement', 'fix'] = 'feature'
version: str = ''
title: dict[str, str] = PydanticUndefined
summary: dict[str, str] = PydanticUndefined
body: dict[str, str] = PydanticUndefined
emoji: str = ''
date: str = ''
class ChangelogImportRequest(ctfy.core.models.CtfyModel):
203class ChangelogImportRequest(CtfyModel):
204    entries: list[ChangelogImportEntry] = Field(default_factory=list)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

entries: list[ChangelogImportEntry] = PydanticUndefined
class ChangelogImportResponse(ctfy.core.models.CtfyModel):
207class ChangelogImportResponse(CtfyModel):
208    """⚠️ ``created`` and ``updated`` are reported separately because
209    they answer different questions. A sync that reports "20 imported"
210    every run says nothing; one that reports "0 created, 20 updated"
211    tells the operator their checkout is in step, and a surprise
212    ``created`` tells them somebody deleted a row."""
213
214    imported: int = 0
215    created: int = 0
216    updated: int = 0

⚠️ created and updated are reported separately because they answer different questions. A sync that reports "20 imported" every run says nothing; one that reports "0 created, 20 updated" tells the operator their checkout is in step, and a surprise created tells them somebody deleted a row.

imported: int = 0
created: int = 0
updated: int = 0
class ChangelogLatestResponse(ctfy.core.models.CtfyModel):
86class ChangelogLatestResponse(CtfyModel):
87    """The nav dot, in one cheap read.
88
89    ⚠️ ``unseen`` is **False for a signed-out visitor**, not "everything
90    is new". The dot is a personal "since you last looked" marker and
91    there is no *you* to compare against; painting it for a guest makes
92    it permanent, which is how a badge stops meaning anything.
93    """
94
95    latest_published_at: datetime | None = None
96    unseen: bool = False

The nav dot, in one cheap read.

⚠️ unseen is False for a signed-out visitor, not "everything is new". The dot is a personal "since you last looked" marker and there is no you to compare against; painting it for a guest makes it permanent, which is how a badge stops meaning anything.

latest_published_at: datetime.datetime | None = None
unseen: bool = False
class ChangelogListResponse(ctfy.core.models.CtfyModel):
73class ChangelogListResponse(CtfyModel):
74    """The public list. Paginated by hand rather than through
75    ``BigLimitOffsetPage`` because the count must be of *published*
76    entries only, and the page slicing has to happen in SQL — an operator
77    with a long backlog of drafts would otherwise page a list whose rows
78    were filtered out after the window was taken."""
79
80    entries: list[ChangelogEntryInfo] = Field(default_factory=list)
81    total: int = 0
82    page: int = 1
83    page_size: int = 20

The public list. Paginated by hand rather than through BigLimitOffsetPage because the count must be of published entries only, and the page slicing has to happen in SQL — an operator with a long backlog of drafts would otherwise page a list whose rows were filtered out after the window was taken.

entries: list[ChangelogEntryInfo] = PydanticUndefined
total: int = 0
page: int = 1
page_size: int = 20
class ChangelogSeenResponse(ctfy.core.models.CtfyModel):
 99class ChangelogSeenResponse(CtfyModel):
100    """Receipt for ``POST /changelog/mark-seen``."""
101
102    seen_at: datetime | None = None

Receipt for POST /changelog/mark-seen.

seen_at: datetime.datetime | None = None
class CheckAnswerRequest(ctfy.core.models.CtfyModel):
598class CheckAnswerRequest(CtfyModel):
599    """Run the checker for one *checker-mode* question on an instance.
600
601    Proof-of-exploitation: the platform execs the question's
602    author-supplied checker inside the trusted judge sidecar; the player
603    submits no answer string — the sidecar emits the minted token only
604    when the exploit's effect is verified.
605    """
606
607    # Which checker question to verify. Checker questions usually carry a
608    # descriptive id (``"deface"`` / ``"persist"`` / …); defaults to
609    # ``"flag"`` for the single-question shape.
610    question_id: str = "flag"

Run the checker for one checker-mode question on an instance.

Proof-of-exploitation: the platform execs the question's author-supplied checker inside the trusted judge sidecar; the player submits no answer string — the sidecar emits the minted token only when the exploit's effect is verified.

question_id: str = 'flag'
class CheckAnswerResponse(ctfy.core.models.CtfyModel):
613class CheckAnswerResponse(CtfyModel):
614    # True when the checker confirmed the exploit (its stdout matched the
615    # instance's minted token for this question) and a solve was recorded.
616    passed: bool
617    # Echoes the question id on a pass; ``None`` otherwise.
618    question_id: str | None = None
619    # Checker process exit code — diagnostic only; the token match is the
620    # real signal. ``None`` when the checker couldn't be run at all.
621    exit_code: int | None = None
622    # True when the checker exceeded its wall-clock budget.
623    timed_out: bool = False
624    # True when the team had already solved this question (short-circuit,
625    # no node call made).
626    already_solved: bool = False
627    # First-blood rank on a fresh solve (1/2/3 = 1st/2nd/3rd blood), 0
628    # otherwise. Mirrors ``SubmissionResponse.solve_rank``.
629    solve_rank: int = 0
630    # True when this check completed the challenge (every question solved).
631    challenge_fully_solved: bool = False
632    # Server clock (epoch seconds) when the check ran, for the UI's
633    # "last checked" line.
634    last_checked_at: float = 0.0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

passed: bool = PydanticUndefined
question_id: str | None = None
exit_code: int | None = None
timed_out: bool = False
already_solved: bool = False
solve_rank: int = 0
challenge_fully_solved: bool = False
last_checked_at: float = 0.0
class ClusterInfo(ctfy.core.models.CtfyModel):
527class ClusterInfo(CtfyModel):
528    """Returned by ``GET /cluster-info``.
529
530    Provides the bits the ``ctfy-admin server invite`` CLI and admin UI wizard
531    need to build a runnable join command. The *registration token* is
532    minted separately via ``POST /nodes/invites`` — it isn't on this
533    payload because cluster info is queried speculatively by the UI on
534    every admin dashboard load, and we never want to hand an invite
535    token to a page that didn't explicitly ask for one.
536    """
537
538    platform_url: str
539    node_image: str

Returned by GET /cluster-info.

Provides the bits the ctfy-admin server invite CLI and admin UI wizard need to build a runnable join command. The registration token is minted separately via POST /nodes/invites — it isn't on this payload because cluster info is queried speculatively by the UI on every admin dashboard load, and we never want to hand an invite token to a page that didn't explicitly ask for one.

platform_url: str = PydanticUndefined
node_image: str = PydanticUndefined
class CompetitionAdminInfo(ctfy.core.models.CtfyModel):
1327class CompetitionAdminInfo(CtfyModel):
1328    """One per-competition admin grant, for the super-admin management
1329    card on the competition detail page."""
1330
1331    user_id: str
1332    display_name: str = ""
1333    email: str = ""
1334    role: CompetitionGrantRole = "admin"
1335    granted_by: str = ""
1336    granted_at: datetime | None = None

One per-competition admin grant, for the super-admin management card on the competition detail page.

user_id: str = PydanticUndefined
display_name: str = ''
email: str = ''
role: Literal['admin', 'reviewer'] = 'admin'
granted_by: str = ''
granted_at: datetime.datetime | None = None
class CompetitionApplicationInfo(ctfy.core.models.CtfyModel):
1508class CompetitionApplicationInfo(CtfyModel):
1509    """The caller's own application to one competition (plan L2).
1510
1511    ⚠️ **No ``decided_by``.** The applicant is told the verdict and the
1512    note, never which organiser ruled: a name on a rejection is somebody
1513    to argue with, and the organisers answer as the event.
1514    """
1515
1516    competition_id: str
1517    message: str = ""
1518    status: ReviewStatus = "pending"
1519    created_at: datetime
1520    #: When an organiser ruled — ``None`` while pending.
1521    decided_at: datetime | None = None
1522    #: What the organisers wrote with a rejection. ``""`` is none.
1523    decision_note: str = ""

The caller's own application to one competition (plan L2).

⚠️ No decided_by. The applicant is told the verdict and the note, never which organiser ruled: a name on a rejection is somebody to argue with, and the organisers answer as the event.

competition_id: str = PydanticUndefined
message: str = ''
status: Literal['pending', 'approved', 'rejected'] = 'pending'
created_at: datetime.datetime = PydanticUndefined
decided_at: datetime.datetime | None = None
decision_note: str = ''
class CompetitionApplicationRequest(ctfy.core.models.CtfyModel):
1498class CompetitionApplicationRequest(CtfyModel):
1499    """Body for ``PUT /competitions/{id}/application`` (plan L2).
1500
1501    ``message`` is what the organisers read in their queue — who you are,
1502    why this event. Optional: the application itself is the request.
1503    """
1504
1505    message: str = Field(default="", max_length=APPLICATION_MESSAGE_MAX)

Body for PUT /competitions/{id}/application (plan L2).

message is what the organisers read in their queue — who you are, why this event. Optional: the application itself is the request.

message: str = ''
class CompetitionApplicationReviewRequest(ctfy.core.models.CtfyModel):
1555class CompetitionApplicationReviewRequest(CtfyModel):
1556    """Body for ``POST /admin/competitions/{id}/applications/{user_id}/review``.
1557
1558    ``note`` goes to the applicant with a rejection, and a rejection
1559    needs one — it is final, and "no" with nothing beside it is a support
1560    ticket. An approval takes none: its answer is the invitation mail.
1561    """
1562
1563    status: ApplicationVerdict
1564    note: str = Field(default="", max_length=APPLICATION_MESSAGE_MAX)

Body for POST /admin/competitions/{id}/applications/{user_id}/review.

note goes to the applicant with a rejection, and a rejection needs one — it is final, and "no" with nothing beside it is a support ticket. An approval takes none: its answer is the invitation mail.

status: Literal['approved', 'rejected'] = PydanticUndefined
note: str = ''
class CompetitionApplicationRow(ctfy.core.models.CtfyModel):
1526class CompetitionApplicationRow(CtfyModel):
1527    """One application in an event's queue, for its organisers (plan L2).
1528
1529    ``display_name`` / ``email`` are the invitations roster's pair,
1530    because the reader is deciding whether to let this person in.
1531    """
1532
1533    competition_id: str
1534    user_id: str
1535    display_name: str = ""
1536    email: str = ""
1537    message: str = ""
1538    status: ReviewStatus = "pending"
1539    created_at: datetime
1540    decided_by: str = ""
1541    #: Resolved from :attr:`decided_by`, because the raw id names nobody.
1542    #: ``""`` while pending, when the organiser's account is gone, and
1543    #: when an address the person proved answered it (the claim has no
1544    #: organiser of its own beyond the one who invited the address).
1545    decided_by_name: str = ""
1546    decided_at: datetime | None = None
1547    decision_note: str = ""
1548    #: Whether the person holds an invitation **now**. Distinct from
1549    #: ``status == "approved"``: an invitation revoked after approval
1550    #: leaves the verdict standing and this false, and a rejected
1551    #: applicant invited directly reads ``rejected`` beside ``True``.
1552    invited: bool = False

One application in an event's queue, for its organisers (plan L2).

display_name / email are the invitations roster's pair, because the reader is deciding whether to let this person in.

competition_id: str = PydanticUndefined
user_id: str = PydanticUndefined
display_name: str = ''
email: str = ''
message: str = ''
status: Literal['pending', 'approved', 'rejected'] = 'pending'
created_at: datetime.datetime = PydanticUndefined
decided_by: str = ''
decided_by_name: str = ''
decided_at: datetime.datetime | None = None
decision_note: str = ''
invited: bool = False
class CompetitionAttachmentInfo(ctfy.core.models.CtfyModel):
1172class CompetitionAttachmentInfo(CtfyModel):
1173    """One file hung on a competition — its rules, handbook or notice (#1808).
1174
1175    ⚠️ **No bytes and no object key.** The bytes are fetched from
1176    ``url``, proxied through the platform behind the competition's own
1177    visibility — the key is exactly the guess that proxying exists to
1178    make useless.
1179    """
1180
1181    id: str
1182    competition_id: str
1183    #: Sanitised, display only — the object key is derived from ``id``.
1184    filename: str
1185    #: Sniffed from the bytes. An ``image/*`` type renders inline; every
1186    #: other type is a download.
1187    content_type: str
1188    size_bytes: int
1189    #: ``""`` shows to every reader; a locale tag shows only to readers of
1190    #: that language. The browser filters — the language is a client-side
1191    #: preference switched without a reload.
1192    locale: str = ""
1193    #: Optional display title per locale; ``{}`` means show ``filename``.
1194    title: LocalizedText = Field(default_factory=dict)
1195    position: int = 0
1196    uploaded_at: datetime | None = None
1197
1198    @classmethod
1199    def of(cls, row: CompetitionAttachmentState) -> CompetitionAttachmentInfo:
1200        """The projection of a stored row — one spelling for every reader
1201        (the list routes, the detail payload), so none drops a field."""
1202        return cls(
1203            id=row.id,
1204            competition_id=row.competition_id,
1205            filename=row.filename,
1206            content_type=row.content_type,
1207            size_bytes=row.size_bytes,
1208            locale=row.locale,
1209            title=row.title,
1210            position=row.position,
1211            uploaded_at=row.uploaded_at,
1212        )
1213
1214    @computed_field
1215    @property
1216    def url(self) -> str:
1217        """The stable address of the bytes, for linking from the handbook
1218        markdown: ``/api/v1/competitions/{id}/attachments/{attachment_id}``.
1219        Derived from the two ids, so it cannot disagree with the route."""
1220        return f"/api/v1/competitions/{self.competition_id}/attachments/{self.id}"

One file hung on a competition — its rules, handbook or notice (#1808).

⚠️ No bytes and no object key. The bytes are fetched from url, proxied through the platform behind the competition's own visibility — the key is exactly the guess that proxying exists to make useless.

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
filename: str = PydanticUndefined
content_type: str = PydanticUndefined
size_bytes: int = PydanticUndefined
locale: str = ''
title: dict[str, str] = PydanticUndefined
position: int = 0
uploaded_at: datetime.datetime | None = None
@classmethod
def of( cls, row: ctfy.core.state.models.CompetitionAttachmentState) -> CompetitionAttachmentInfo:
1198    @classmethod
1199    def of(cls, row: CompetitionAttachmentState) -> CompetitionAttachmentInfo:
1200        """The projection of a stored row — one spelling for every reader
1201        (the list routes, the detail payload), so none drops a field."""
1202        return cls(
1203            id=row.id,
1204            competition_id=row.competition_id,
1205            filename=row.filename,
1206            content_type=row.content_type,
1207            size_bytes=row.size_bytes,
1208            locale=row.locale,
1209            title=row.title,
1210            position=row.position,
1211            uploaded_at=row.uploaded_at,
1212        )

The projection of a stored row — one spelling for every reader (the list routes, the detail payload), so none drops a field.

url: str
1214    @computed_field
1215    @property
1216    def url(self) -> str:
1217        """The stable address of the bytes, for linking from the handbook
1218        markdown: ``/api/v1/competitions/{id}/attachments/{attachment_id}``.
1219        Derived from the two ids, so it cannot disagree with the route."""
1220        return f"/api/v1/competitions/{self.competition_id}/attachments/{self.id}"

The stable address of the bytes, for linking from the handbook markdown: /api/v1/competitions/{id}/attachments/{attachment_id}. Derived from the two ids, so it cannot disagree with the route.

class CompetitionAttachmentUpdate(ctfy.core.models.CtfyModel):
1252class CompetitionAttachmentUpdate(CtfyModel):
1253    """An organiser's edit to one attachment. Partial: an omitted field is
1254    left alone (``model_fields_set``), so re-titling does not reset the
1255    locale — the defect the token editor already paid for."""
1256
1257    #: ``{}`` clears the title back to the filename.
1258    title: LocalizedText | None = None
1259    locale: str | None = None
1260    position: int | None = Field(default=None, ge=0, le=100_000)
1261
1262    _v_title = field_validator("title", mode="before")(
1263        lambda v: localized_field(v, cap=MAX_ATTACHMENT_TITLE_CHARS, field="title")
1264    )
1265    _v_locale = field_validator("locale", mode="before")(attachment_locale)

An organiser's edit to one attachment. Partial: an omitted field is left alone (model_fields_set), so re-titling does not reset the locale — the defect the token editor already paid for.

title: dict[str, str] | None = None
locale: str | None = None
position: int | None = None
class CompetitionAttachmentsResponse(ctfy.core.models.CtfyModel):
1223class CompetitionAttachmentsResponse(CtfyModel):
1224    """A competition's files, in display order — every locale's.
1225
1226    ⚠️ **Unfiltered by language on purpose.** Which files a reader sees
1227    is their language, a browser preference switched without a reload;
1228    a server-side filter would follow ``Accept-Language`` and not the
1229    toggle — ``resolve_localized``'s reason for resolving in the client.
1230    """
1231
1232    competition_id: str
1233    attachments: list[CompetitionAttachmentInfo] = Field(default_factory=list)

A competition's files, in display order — every locale's.

⚠️ Unfiltered by language on purpose. Which files a reader sees is their language, a browser preference switched without a reload; a server-side filter would follow Accept-Language and not the toggle — resolve_localized's reason for resolving in the client.

competition_id: str = PydanticUndefined
attachments: list[CompetitionAttachmentInfo] = PydanticUndefined
class CompetitionChallengeBreakdown(ctfy.core.models.CtfyModel):
1347class CompetitionChallengeBreakdown(CtfyModel):
1348    challenges: list[CompetitionChallengeRow] = Field(default_factory=list)
1349    generated_at: float = 0.0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

challenges: list[CompetitionChallengeRow] = PydanticUndefined
generated_at: float = 0.0
class CompetitionChallengeRow(ctfy.core.models.CtfyModel):
1285class CompetitionChallengeRow(CtfyModel):
1286    """Per-challenge competition aggregate: solve count, attempt
1287    success rate, and first blood. Reconstructed from the solve log
1288    with the same window / team filter as the scoreboard so the
1289    numbers always agree with the leaderboard."""
1290
1291    challenge_id: str
1292    name: str = ""
1293    category: ChallengeCategory | None = None
1294    difficulty: str = ""
1295    # Teams that captured every declared flag (matches scoreboard's
1296    # ``solved`` tally).
1297    solves_count: int = 0
1298    # In-window submissions for this challenge across registered teams.
1299    attempts: int = 0
1300    success_rate: float = 0.0
1301    # What one flag of this challenge is worth **right now**, under this
1302    # competition's own scoring rule, at the solver count above.
1303    #
1304    # ⚠️ **``None`` means the board does not price challenges, and it is
1305    # not ``0``.** On a ``flag_count`` board the warm-up and the last
1306    # link of a five-stage chain are both worth one flag, so there is no
1307    # price to publish — and a zero would read as "this challenge is
1308    # worthless", a claim about the competition nobody made. The panel
1309    # that reads this is meant to be *absent* there, not full of zeroes.
1310    #
1311    # ⚠️ Server-composed for the reason every axis on `CompetitionInfo`
1312    # is: the arithmetic is ``PointsRule._decayed``, and a client that
1313    # recomputed decay would be the copy a reader is looking at when the
1314    # two disagree — on the one board whose whole point is that the
1315    # numbers move while nobody solves anything.
1316    value: int | None = None
1317    # The same flag's value at one solver, so a panel can show how far it
1318    # has fallen without re-deriving the curve. ⚠️ Absent on the same
1319    # terms as ``value``: two fields, one question.
1320    base_value: int | None = None
1321    #: ``(solvers, value)`` from the first capture until the price stops
1322    #: moving — the shape between ``base_value`` and ``value``.
1323    #:
1324    #: ⚠️ **Sampled through the scoring rule, never re-derived.** Drawing
1325    #: this from the decay config in a panel would be a second
1326    #: implementation of ``PointsRule._decayed`` and the copy a reader is
1327    #: looking at when the two disagree.
1328    #:
1329    #: ⚠️ **Empty on a board that does not price**, matching ``value``'s
1330    #: ``None`` — never a flat line at zero, which would be a price
1331    #: nobody set. A board with no *decay* is a flat line at the base,
1332    #: which is two points rather than none.
1333    decay: list[ChallengeValuePoint] = Field(default_factory=list)
1334    # First team to solve this challenge in-window. Empty when unsolved.
1335    first_blood_team_id: str = ""
1336    first_blood_team_name: str = ""
1337    first_blood_at: datetime | None = None
1338    # ⚠️ Named for the *attribution*, not for the row: every other field
1339    # here is a fact about the challenge, and a bare ``anonymous`` on
1340    # this model would read as "this challenge is anonymous". Empty
1341    # ``first_blood_team_id`` with this ``False`` is an unsolved
1342    # challenge; empty with it ``True`` would be a contradiction, so the
1343    # hidden case blanks the trio and leaves this alone.
1344    first_blood_anonymous: bool = False

Per-challenge competition aggregate: solve count, attempt success rate, and first blood. Reconstructed from the solve log with the same window / team filter as the scoreboard so the numbers always agree with the leaderboard.

challenge_id: str = PydanticUndefined
name: str = ''
category: ctfy.challenge.benchmark.ChallengeCategory | None = None
difficulty: str = ''
solves_count: int = 0
attempts: int = 0
success_rate: float = 0.0
value: int | None = None
base_value: int | None = None
decay: list[ChallengeValuePoint] = PydanticUndefined
first_blood_team_id: str = ''
first_blood_team_name: str = ''
first_blood_at: datetime.datetime | None = None
first_blood_anonymous: bool = False
class CompetitionCreate(ctfy.core.models.CtfyModel):
415class CompetitionCreate(CtfyModel):
416    """Request body for ``POST /admin/competitions``.
417
418    Time fields accept ISO 8601 strings; empty means "no bound" (live
419    immediately / never expires). ``challenge_ids`` is validated against
420    the spec registry inside the route handler — unknown ids return 422.
421    """
422
423    title: str = Field(min_length=1, max_length=200)
424    #: ``{locale: text}``; a bare string is read as the base locale.
425    description: LocalizedText = Field(default_factory=dict)
426    #: The organiser's long-form rules page, markdown. See
427    #: ``CompetitionState.handbook`` — ``description`` stays the blurb
428    #: its four render sites truncate, this is the document.
429    handbook: LocalizedText = Field(default_factory=dict)
430
431    _v_description = field_validator("description", mode="before")(
432        lambda v: localized_field(v, cap=MAX_DESCRIPTION_CHARS, field="description")
433    )
434    _v_handbook = field_validator("handbook", mode="before")(
435        lambda v: localized_field(v, cap=MAX_HANDBOOK_CHARS, field="handbook")
436    )
437    starts_at: datetime | None = None
438    ends_at: datetime | None = None
439    # No fixed length cap: the real bound is ``_check_challenge_ids`` in
440    # ``admin_competitions`` (every id must be a known spec, so a valid list
441    # can never exceed the catalog). A magic number here is arbitrary and a
442    # recurring foot-gun as the corpus grows — bucket comps already exceed 1000.
443    challenge_ids: list[str] = Field(default_factory=list)
444    # New competitions start hidden ("draft") so admins can prepare them
445    # privately and publish when ready.
446    status: CompetitionStatus = "draft"
447    # Participation access (orthogonal to ``status``). ``public`` is open
448    # to everyone; ``private_listed`` / ``private_hidden`` require an admin
449    # invite to register (the latter also hides the comp from non-invited
450    # users). Defaults to ``public`` so ad-hoc API callers stay open.
451    access: CompetitionAccess = "public"
452    # Marks this competition as an operator-curated eval **standard set** —
453    # only such a competition may back an ``official`` EvalCampaign
454    # (docs/model-eval-vendor-tenancy.md §4.4). ``corpus_sha`` optionally pins
455    # the benchmark-corpus snapshot it was certified against. Off by default.
456    eval_standard: bool = False
457    corpus_sha: str = ""
458    # May a user enter their own agent here? Its own axis, deliberately
459    # not ``eval_standard`` above — that one means "a curated,
460    # reproducible standard set", which is a claim about the challenge
461    # list, not about who may play. ⚠️ Defaults **open**, unlike every
462    # other marker on this model: an API caller who says nothing gets a
463    # competition agents may enter. See CompetitionState.agents_allowed.
464    agents_allowed: bool = True
465    # Do entrants write challenges for each other here? ⚠️ Defaults
466    # **off**, unlike ``agents_allowed`` directly above and for the
467    # opposite reason: that one says who may enter an event, this one
468    # opens an upload door, and a caller who says nothing must not get
469    # one. See CompetitionState.authoring_enabled.
470    authoring_enabled: bool = False
471    # Does this event's wall draw its agents' model traffic? ⚠️
472    # ``False`` for a caller who says nothing, `authoring_enabled`'s
473    # reasoning rather than `agents_allowed`'s: this one opens a panel
474    # on a *public, tokenless* projector page, so silence must not open
475    # it. See CompetitionState.agent_pulse.
476    agent_pulse: bool = False
477    # Is the field of entrants dispatched agents, not humans — an agent
478    # exhibition? ⚠️ Defaults **off**, ``authoring_enabled``'s reasoning
479    # rather than ``agents_allowed``'s: this closes the human sign-up
480    # door, so a caller who says nothing must get an ordinary,
481    # human-registerable event. See CompetitionState.entrants_are_agents.
482    entrants_are_agents: bool = False
483    # The authoring window, distinct from both the play window and the
484    # registration one; unset on both ends means "for as long as the
485    # event runs". `core.competitions.authoring_phase` is the rule.
486    authoring_starts_at: datetime | None = None
487    authoring_ends_at: datetime | None = None
488    # Uploads one team may have awaiting review here; ``0`` defers to the
489    # platform's ``max_pending_authored_challenges``, ``max_team_size``'s
490    # shape. See CompetitionState.authoring_pending_per_team.
491    authoring_pending_per_team: int = Field(default=0, ge=0)
492    # Uploads that may await review here at once, every team together;
493    # ``0`` = no capacity. See CompetitionState.authoring_queue_capacity.
494    authoring_queue_capacity: int = Field(default=0, ge=0)
495    # The contributor agreement an upload here must accept: its version
496    # and where to read it, both or neither (``""`` = none asked). See
497    # CompetitionState.authoring_terms_version.
498    authoring_terms_version: str = Field(default="", max_length=64)
499    authoring_terms_url: WebUrl = Field(default="", max_length=500)
500    # Which rule ranks this competition's board, and its tuning. Empty
501    # selects the platform default (``flag_count``), so an API caller
502    # that says nothing gets exactly the historic behaviour. Set it here
503    # rather than later where you can: the rule freezes once the
504    # competition goes live (see ``scoring_locked``).
505    scoring_rule: ScoringRuleName | Literal[""] = ""
506    scoring_params: dict[str, Any] = Field(default_factory=dict)
507    # Silent exercise mode, orthogonal to ``scoring_rule`` on purpose.
508    # ``CompetitionState.silent`` is the derivation.
509    silent: bool = False
510    # Playable slices inside the window. Empty (the default) means the
511    # whole ``starts_at``..``ends_at`` window is playable, which is how
512    # every competition has always behaved. Supplying sessions also
513    # *sets* the window: ``CompetitionState`` derives ``starts_at`` /
514    # ``ends_at`` from the envelope, so an organiser sending sessions
515    # need not compute the outer dates and cannot contradict them.
516    sessions: list[CompetitionSessionInfo] = Field(default_factory=list)
517    # Seconds after the play window closes during which the engagement
518    # report may still be saved. ``0`` (the default) means the report
519    # closes with the event, which is exactly where the play gate closed
520    # it before this field existed. See ``CompetitionState.report_window_s``.
521    report_window_s: int = 0
522    # Whether an entrant may withhold their identity here, and what
523    # withholding looks like. Defaults ``forbidden`` — unlike
524    # ``agents_allowed`` directly above, and for the opposite reason: an
525    # API caller who says nothing must not get an event whose board
526    # fills with pseudonyms nobody chose, and a real-name event (the
527    # population this platform's PII dossier exists for) must be what
528    # you get by default. See ``CompetitionState.anonymous_entry``.
529    anonymous_entry: AnonymityPolicy = "forbidden"
530    anonymous_entry_kind: AnonymityKind = "pseudonymous"
531    # --- registration ---------------------------------------------------
532    # The window sign-ups are open, distinct from the play window above.
533    # ``CompetitionState.registration_starts_at`` is the derivation.
534    registration_starts_at: datetime | None = None
535    registration_ends_at: datetime | None = None
536    # Per-competition roster cap. 0 defers to the platform-wide setting,
537    # so two events on one deployment can carry different rules.
538    max_team_size: int = Field(default=0, ge=0)
539    # When true a newly registered team lands ``pending`` and cannot play
540    # until an organiser approves it.
541    registration_review: bool = False
542    # field name -> policy; anything absent is treated as hidden.
543    # ``CompetitionState.registration_fields`` is the derivation.
544    registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict)
545    # Days after ``ends_at`` before the retention sweep blanks the three
546    # sealed columns (real name, phone, postal address). ``0`` keeps them
547    # indefinitely, which is what every competition predating this reads
548    # as — the purge is irreversible, so turning it on is a decision
549    # somebody makes about one event, never a default.
550    pii_retention_days: int = Field(default=0, ge=0)
551
552    @field_validator("registration_fields")
553    @classmethod
554    def _known_registration_fields(
555        cls, v: dict[str, RegistrationFieldPolicy]
556    ) -> dict[str, RegistrationFieldPolicy]:
557        return _check_registration_fields(v)
558
559    _norm_times = field_validator("starts_at", "ends_at", mode="before")(_blank_to_none)
560
561    #: The rounds this competition runs, configured in the same request.
562    #:
563    #: ⚠️ **Optional, and its absence is not "no match" — it is "not
564    #: configured here".** A match is a side table keyed by competition
565    #: id and ``PUT …/awd/match`` is an upsert, so a competition created
566    #: without one can still be given one afterwards; that is how every
567    #: match was made before this field existed. What it removes is the
568    #: *dead end*: the ``awd_classic`` preset used to hand back a
569    #: competition that looked configured and was inert, with the one
570    #: thing that makes it a match reachable only from another page.
571    #:
572    #: ⚠️ **Not derived from ``scoring_rule``.** ``awd`` names how a
573    #: board is *ranked* and says nothing about rounds —
574    #: ``awd_scoreboard`` never reads it (quote ``grep -c scoring_rule
575    #: ctfy/server/awd_scoreboard.py``) — so a competition may rank as
576    #: AWD with no match, and a match may run under any rule. Coupling
577    #: them here would make one axis silently decide the other.
578    awd_match: AwdMatchConfig | None = None

Request body for POST /admin/competitions.

Time fields accept ISO 8601 strings; empty means "no bound" (live immediately / never expires). challenge_ids is validated against the spec registry inside the route handler — unknown ids return 422.

title: str = PydanticUndefined
description: dict[str, str] = PydanticUndefined
handbook: dict[str, str] = PydanticUndefined
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
challenge_ids: list[str] = PydanticUndefined
status: Literal['draft', 'published', 'archived'] = 'draft'
access: Literal['public', 'private_listed', 'private_hidden'] = 'public'
eval_standard: bool = False
corpus_sha: str = ''
agents_allowed: bool = True
authoring_enabled: bool = False
agent_pulse: bool = False
entrants_are_agents: bool = False
authoring_starts_at: datetime.datetime | None = None
authoring_ends_at: datetime.datetime | None = None
authoring_pending_per_team: int = 0
authoring_queue_capacity: int = 0
authoring_terms_version: str = ''
authoring_terms_url: Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x72ea04d2a980>)] = ''
scoring_rule: Union[Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring'], Literal['']] = ''
scoring_params: dict[str, typing.Any] = PydanticUndefined
silent: bool = False
sessions: list[CompetitionSessionInfo] = PydanticUndefined
report_window_s: int = 0
anonymous_entry: Literal['forbidden', 'optional', 'required'] = 'forbidden'
anonymous_entry_kind: Literal['pseudonymous', 'hidden'] = 'pseudonymous'
registration_starts_at: datetime.datetime | None = None
registration_ends_at: datetime.datetime | None = None
max_team_size: int = 0
registration_review: bool = False
registration_fields: dict[str, typing.Literal['hidden', 'optional', 'required']] = PydanticUndefined
pii_retention_days: int = 0
awd_match: AwdMatchConfig | None = None
class CompetitionDetail(ctfy.server.models.CompetitionInfo):
1268class CompetitionDetail(CompetitionInfo):
1269    """Full detail payload — includes a *preview* of the resolved
1270    challenge summaries and the caller's registration state when
1271    authenticated.
1272
1273    ⚠️ ``challenges`` is bounded by
1274    :data:`~ctfy.server.routes.competitions.COMPETITION_CHALLENGE_PREVIEW`
1275    and is **not** the competition's challenge set. It used to be, and a
1276    bucket competition put hundreds of full ``ChallengeInfo`` rows —
1277    each carrying questions, attachments and a defence block — through
1278    a payload whose only reader renders six cards.
1279
1280    Anything that means *all of them* must page
1281    ``GET /competitions/{id}/challenges`` (SDK:
1282    ``client.competition(id).challenges(offset, limit)``), and anything
1283    that means *how many* must read ``challenge_count``. Both existed as
1284    ``len(challenges)`` before the bound and would have kept working
1285    while reporting the preview size — which is why the two aggregates
1286    below are served rather than left to the caller to derive.
1287    """
1288
1289    #: The organiser's long-form rules page, markdown.
1290    #:
1291    #: ⚠️ **On the detail payload and deliberately not on
1292    #: ``CompetitionInfo``.** ``GET /competitions`` pages 50 rows of that
1293    #: model at a time and this field is capped at 20 000 characters, so
1294    #: publishing it there is ``AuthoredChallengeSummary``'s recorded
1295    #: defect — a column projection carrying the blob. The rules page
1296    #: reads it off ``CompetitionContext``, which ``CompetitionLayout``
1297    #: already fetched, so it costs no request of its own.
1298    handbook: LocalizedText = Field(default_factory=dict)
1299    #: When the handbook last really changed; ``None`` while nobody has
1300    #: written one. Its own column rather than ``updated_at``, which
1301    #: moves whenever any field does — see ``CompetitionState``.
1302    handbook_updated_at: datetime | None = None
1303    #: The organiser's files — rules, handbook, notice (#1808) — in
1304    #: display order and **every locale's**; the page filters to the
1305    #: reader's language. On the detail payload for the handbook's own
1306    #: reason: the rules page reads the context and costs no request.
1307    attachments: list[CompetitionAttachmentInfo] = Field(default_factory=list)
1308
1309    challenges: list[ChallengeInfo] = Field(default_factory=list)
1310    # The true totals, over the whole resolved set rather than the
1311    # preview. Derived here because a client cannot recover them from a
1312    # bounded list, and two clients deriving them differently is two
1313    # clients disagreeing about how big the competition is.
1314    challenge_count: int = 0
1315    difficulty_counts: dict[str, int] = Field(default_factory=dict)
1316    registered_at: datetime | None = None

Full detail payload — includes a preview of the resolved challenge summaries and the caller's registration state when authenticated.

⚠️ challenges is bounded by ~ctfy.server.routes.competitions.COMPETITION_CHALLENGE_PREVIEW and is not the competition's challenge set. It used to be, and a bucket competition put hundreds of full ChallengeInfo rows — each carrying questions, attachments and a defence block — through a payload whose only reader renders six cards.

Anything that means all of them must page GET /competitions/{id}/challenges (SDK: client.competition(id).challenges(offset, limit)), and anything that means how many must read challenge_count. Both existed as len(challenges) before the bound and would have kept working while reporting the preview size — which is why the two aggregates below are served rather than left to the caller to derive.

handbook: dict[str, str] = PydanticUndefined
handbook_updated_at: datetime.datetime | None = None
attachments: list[CompetitionAttachmentInfo] = PydanticUndefined
challenges: list[ChallengeInfo] = PydanticUndefined
challenge_count: int = 0
difficulty_counts: dict[str, int] = PydanticUndefined
registered_at: datetime.datetime | None = None
class CompetitionFormatPreset(ctfy.core.models.CtfyModel):
309class CompetitionFormatPreset(CtfyModel):
310    """One named starting point for the create-competition wizard.
311
312    ⚠️ **Served rather than mirrored in the frontend, and the reason is
313    ``scoring_params``.** A preset's params have to survive
314    ``build_rule``, which is strict — a client-side copy naming a curve
315    this server does not accept would pass every frontend check and 422
316    the organiser on the wizard's last step, with the cause six screens
317    back. Keeping the catalogue on the side that validates it makes that
318    class of drift impossible rather than tested-for.
319
320    Read-only and unstored: see ``ctfy.core.competition_formats`` for why
321    a competition never records which preset made it.
322    """
323
324    id: str
325    #: Empty selects the platform default, exactly as it does on
326    #: ``CompetitionCreate`` — see the core module's note on why a
327    #: preset does not spell today's default out.
328    scoring_rule: ScoringRuleName | Literal[""] = ""
329    scoring_params: dict[str, Any] = Field(default_factory=dict)
330    silent: bool = False
331    #: This format is configured in the competition's own match settings
332    #: and does not read ``scoring_rule`` at all.
333    needs_awd_match: bool = False
334    #: The format's deliverable has no deadline until one is set, and
335    #: ``0`` closes the report with the event rather than meaning unset.
336    needs_report_window: bool = False
337    #: Whether this format needs the authoring door open. A board that
338    #: scores what entrants wrote is inert without it — see the core
339    #: dataclass for why this axis is set rather than surfaced.
340    authoring_enabled: bool = False
341    #: Who attacks each round: ``none`` is a melee between the entrants,
342    #: anything else is defence-only. A format axis, not a knob — see
343    #: the core dataclass for what picking the wrong one costs.
344    awd_attacker: AwdAttacker = AwdAttacker.NONE
345    #: The **match's** scoring knobs, not the competition's. Empty means
346    #: the packaged defaults; a defence-only format has to say something
347    #: here or its optimal opening is for a defender to stop their own
348    #: service. ``DEFENCE_ONLY_KNOBS`` carries the arithmetic.
349    awd_scoring_params: dict[str, float] = Field(default_factory=dict)
350    #: Is the field of entrants dispatched agents rather than humans — an
351    #: agent exhibition? The wizard seeds ``entrants_are_agents`` on the
352    #: created competition from this. See the core dataclass.
353    entrants_are_agents: bool = False

One named starting point for the create-competition wizard.

⚠️ Served rather than mirrored in the frontend, and the reason is scoring_params. A preset's params have to survive build_rule, which is strict — a client-side copy naming a curve this server does not accept would pass every frontend check and 422 the organiser on the wizard's last step, with the cause six screens back. Keeping the catalogue on the side that validates it makes that class of drift impossible rather than tested-for.

Read-only and unstored: see ctfy.core.competition_formats for why a competition never records which preset made it.

id: str = PydanticUndefined
scoring_rule: Union[Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring'], Literal['']] = ''
scoring_params: dict[str, typing.Any] = PydanticUndefined
silent: bool = False
needs_awd_match: bool = False
needs_report_window: bool = False
authoring_enabled: bool = False
awd_attacker: ctfy.core.enums.AwdAttacker = <AwdAttacker.NONE: 'none'>
awd_scoring_params: dict[str, float] = PydanticUndefined
entrants_are_agents: bool = False
class CompetitionInfo(ctfy.core.models.CtfyModel):
 807class CompetitionInfo(CtfyModel):
 808    """Summary row used in the list view.
 809
 810    Per-viewer fields (``is_registered`` / ``my_*``) are populated only
 811    when the request carries an authenticated user; for anonymous
 812    requests they fall back to sentinels (empty strings, ``-1``) so
 813    the response shape stays stable. Front-end treats ``my_rank < 0``
 814    as "no value" rather than "rank zero".
 815    """
 816
 817    id: str
 818    title: str
 819    #: ``{locale: text}`` — resolved in the browser by
 820    #: ``lib/localizedText.ts`` so it follows the language toggle
 821    #: without a refetch, and server-side by
 822    #: ``ctfy.core.localized.resolve_localized`` where there is no
 823    #: browser (email, the CLI).
 824    description: LocalizedText = Field(default_factory=dict)
 825    starts_at: datetime | None = None
 826    ends_at: datetime | None = None
 827    created_at: datetime | None = None
 828    updated_at: datetime | None = None
 829    created_by: str
 830    created_by_name: str
 831    challenge_ids: list[str] = Field(default_factory=list)
 832    # Visibility gate. Only ``published`` rows reach non-admins; admins
 833    # and per-competition admins also receive ``draft`` / ``archived``.
 834    status: CompetitionStatus = "published"
 835    # Participation access. ``public`` is open; ``private_listed`` stays
 836    # discoverable but invite-only to register; ``private_hidden`` is also
 837    # hidden from non-invited users. Default ``public`` for backward
 838    # compat (legacy rows / anonymous responses).
 839    access: CompetitionAccess = "public"
 840    #: The series this contest is an occurrence of, empty for a one-off.
 841    #: Carried so a client can reach the **ladder** — before this the id
 842    #: was on no wire model, so the whole rating surface was unreachable
 843    #: from the browser however public its route was. Not a disclosure:
 844    #: it names a series whose ladder the reader may already read, and
 845    #: nothing about the series' pool or cadence travels with it.
 846    series_id: str = ""
 847    # Eval standard-set marker + pinned corpus snapshot (admin-only meaning;
 848    # see CompetitionState). Surfaced so the admin form can show/edit it.
 849    eval_standard: bool = False
 850    corpus_sha: str = ""
 851    #: What this board ranks by, in precedence order — the first entry
 852    #: is the headline number and the rest are the tie-breaks.
 853    #:
 854    #: ⚠️ **A property of the rule, so it belongs on the competition
 855    #: rather than on the board page.** The rule freezes once the
 856    #: competition goes live (``scoring_locked``), so this is
 857    #: configuration and not progress; putting it on the paginated board
 858    #: would repeat it per page and make page two able to disagree with
 859    #: page one.
 860    #:
 861    #: ⚠️ Empty from a server that predates it, which reads as "no
 862    #: explanation offered" rather than "nothing was sorted" — the
 863    #: client renders nothing instead of claiming the board is unranked.
 864    ordering: list[BoardCriterion] = Field(default_factory=list)
 865    #: Whether a user may enter their own agent here. Public, because the
 866    #: competition page renders a different thing for each answer and a
 867    #: client cannot derive it — ``eval_standard`` is a *different*
 868    #: question and reading it as this one is the bug this replaces.
 869    #:
 870    #: ⚠️ Defaults ``True`` here too, so an older server that does not
 871    #: send the field leaves the page offering the door rather than
 872    #: taking it away. Gates fail open.
 873    agents_allowed: bool = True
 874    #: Whether this competition asks entrants to write challenges.
 875    #:
 876    #: ⚠️ Defaults **False** here, the opposite of ``agents_allowed``
 877    #: directly above, and for the opposite reason. That one is a
 878    #: *gate*, so an older server that omits it must leave the door
 879    #: open. This one is an *offer*: a client reading a default of
 880    #: ``True`` would show every player of every event an authoring
 881    #: page that refuses them. Silence must claim nothing, and here
 882    #: claiming nothing is ``False``.
 883    authoring_enabled: bool = False
 884    #: Does this event's wall draw its agents' model traffic? ⚠️
 885    #: Defaults ``False`` on both sides, and the client reads it as the
 886    #: scene's one axis — so an older server saying nothing leaves the
 887    #: wall exactly as it was rather than growing a panel nobody armed.
 888    agent_pulse: bool = False
 889    #: Is the field of entrants dispatched agents, not humans — an agent
 890    #: exhibition? The browser reads this to replace the "register" CTA
 891    #: with a spectate affordance and to badge the card/detail page.
 892    #:
 893    #: ⚠️ Defaults **False** here, ``authoring_enabled``'s direction. A
 894    #: client reading an older server's silence as ``True`` would take
 895    #: the sign-up button away from a real, human-registerable event —
 896    #: the over-blocking failure. The server still refuses a human on an
 897    #: exhibition regardless (``register_for_competition``); this field
 898    #: only decides what the page *offers*. See
 899    #: CompetitionState.entrants_are_agents.
 900    entrants_are_agents: bool = False
 901    # The authoring window, distinct from both the play window and the
 902    # registration one; unset on both ends means "for as long as the
 903    # event runs". `core.competitions.authoring_phase` is the rule.
 904    authoring_starts_at: datetime | None = None
 905    authoring_ends_at: datetime | None = None
 906    #: This competition's own per-team pending cap (``0`` = the
 907    #: platform's) and its whole review queue's capacity (``0`` = none).
 908    #: Carried as stored, the way ``max_team_size`` is, so the edit form
 909    #: prefills what the organiser set rather than a resolved number it
 910    #: would then save back as an override. Not sensitive: both are
 911    #: rules an author meets at the door anyway.
 912    authoring_pending_per_team: int = 0
 913    authoring_queue_capacity: int = 0
 914    #: The contributor agreement an upload must accept — the version in
 915    #: force and where to read it; ``""`` when the event asks for none.
 916    #: Public, because the author reads both before uploading.
 917    authoring_terms_version: str = ""
 918    authoring_terms_url: str = ""
 919    # Registration window + form config. The window is genuinely public
 920    # information — an entrant needs to know when sign-ups close, and it
 921    # differs from the play window — and the rest lets the admin edit form
 922    # prefill without a second fetch. Nothing here is sensitive: the same
 923    # field policy is already served to any signed-in user by
 924    # ``GET /competitions/{id}/registration``.
 925    registration_starts_at: datetime | None = None
 926    registration_ends_at: datetime | None = None
 927    max_team_size: int = 0
 928    registration_review: bool = False
 929    registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict)
 930    # Public deliberately: how long entrants' personal data is kept is
 931    # part of the notice they are owed, not an internal setting.
 932    pii_retention_days: int = 0
 933    # Which rule ranks this board, and its tuning. Empty name means the
 934    # platform default (``flag_count``). Public because the players are
 935    # entitled to know how they are being scored.
 936    scoring_rule: ScoringRuleName | Literal[""] = ""
 937    scoring_params: dict[str, Any] = Field(default_factory=dict)
 938    # Silent exercise mode, orthogonal to ``scoring_rule`` on purpose.
 939    # ``CompetitionState.silent`` is the derivation.
 940    silent: bool = False
 941    # Whether an entrant here may withhold their identity, and what
 942    # withholding looks like. Public for the reason ``scoring_rule``
 943    # above it is: somebody deciding whether to enter is entitled to
 944    # know, and the admin edit form prefills from this row.
 945    #
 946    # ⚠️ Defaults ``forbidden`` here too, unlike ``agents_allowed``
 947    # directly above. That one is a *door* and gates fail open; this is
 948    # a *promise*, and a client reading an older server's silence as
 949    # "anonymity is on offer" would show a control that stores nothing —
 950    # the worst failure this feature has, since the entrant believes
 951    # they answered.
 952    anonymous_entry: AnonymityPolicy = "forbidden"
 953    anonymous_entry_kind: AnonymityKind = "pseudonymous"
 954    # Server-derived: whether the rule is now frozen. The admin form
 955    # disables the control off this rather than re-deriving the
 956    # draft/phase rule client-side and disagreeing with the server.
 957    scoring_locked: bool = False
 958    # Server-derived projection. ``"upcoming"`` / ``"running"`` / ``"past"``
 959    # — saves the frontend from re-implementing the same string-compare
 960    # the backend already does.
 961    phase: Literal["upcoming", "running", "past"] = "running"
 962    # Declared play sessions, empty for a single-window competition.
 963    # Public: an entrant has to know the event runs 08:00–16:00 daily,
 964    # and the schedule is the first thing a competition page shows.
 965    sessions: list[CompetitionSessionInfo] = Field(default_factory=list)
 966    # Server-derived: whether play is open *right now*. Deliberately not
 967    # folded into ``phase`` — a competition between its 16:00 and the
 968    # next 08:00 is still running (board live, registration open, scoring
 969    # window unmoved), and re-classifying it would change what every
 970    # existing ``phase`` consumer says about a live event.
 971    #
 972    # ``True`` for every competition with no sessions, which is the whole
 973    # existing corpus: they are gated by nothing, so play is never shut.
 974    play_open: bool = True
 975    # When play reopens. ``None`` means there is no next session — either
 976    # none are declared, or the last one has ended. The closed-for-now
 977    # card counts down to this, so it is the one field it needs.
 978    next_session_starts_at: datetime | None = None
 979    # When the current session closes. ``None`` outside a session, and
 980    # also for a competition that declares none — there is no session to
 981    # name, and reporting the window's end here would put a "session ends
 982    # at" countdown on an event that never had sessions.
 983    current_session_ends_at: datetime | None = None
 984    # How long after the event the engagement report stays writable.
 985    # Public for the same reason ``sessions`` is: a team writing one has
 986    # to know when it locks. ``0`` means it closes with the event.
 987    report_window_s: int = 0
 988    # Server-derived: when the report window shuts. ``None`` for an
 989    # open-ended competition — there is no instant to count from, and a
 990    # fabricated deadline is worse than none.
 991    #
 992    # ⚠️ Deliberately *not* a ``report_open`` bool. That answer moves with
 993    # the clock, so a cached page would assert a state that has since
 994    # changed; an instant stays true and the client counts down from it.
 995    report_closes_at: datetime | None = None
 996    # Whether this competition runs a classic AWD tick loop. Server-derived
 997    # from the ``awd_matches`` side table through ``core.awd.is_awd_match``,
 998    # never re-derived per client: it decides which board a player is sent
 999    # to (``/awd`` vs ``/scoreboard``) and whether a gamebox page exists at
1000    # all, so two clients answering it differently is two clients
1001    # disagreeing about how the event is played.
1002    #
1003    # A bool rather than a ``format`` enum on purpose. ``docs/awd-plan.md``
1004    # §14.1 refuses a ``GameMode`` taxonomy at N=2 — a format is a point on
1005    # six orthogonal axes, not a subclass — and this field claims only the
1006    # fact the side table already stores. ``False`` is every competition
1007    # that predates AWD, which is the whole existing corpus.
1008    is_awd_match: bool = False
1009    # Whether this competition's standings live in the match rather than
1010    # in the scoring rule — ``is_awd_match and not challenge_ids``.
1011    #
1012    # ⚠️ **Not a rename of ``is_awd_match``, and the conjunction is the
1013    # whole field.** A competition may run a match *and* hang challenges
1014    # (``UserCompetition.is_awd_match``'s own comment anticipates the
1015    # mixed case), and that competition's Jeopardy board is real; keying
1016    # a board on the match alone would blank one with solves on it.
1017    #
1018    # ⚠️ It exists because the failure is **not** the empty board it
1019    # looks like. Measured on a pure rounds match with three settled
1020    # rounds: ``/scoreboard`` answers with every team at zero and ranks
1021    # complete, so the order falls to the tie break and puts the team
1022    # that is *losing* on top, while ``/awd/board`` carries the real one.
1023    # An empty board reads as broken and gets investigated; a full board
1024    # where everybody has zero reads as "the match just started" — and it
1025    # names the wrong leader on a projector.
1026    # ``ctfy/tests/test_pure_rounds_match_standings.py`` keeps that
1027    # measurement as a test rather than as this paragraph.
1028    #
1029    # ⚠️ Composed here rather than ``&&``-ed in the browser, for the
1030    # reason ``expects_report`` is: two clients composing it differently
1031    # is two clients disagreeing about where an event's standings are.
1032    # The wall and the leaderboard both need it, which is already two.
1033    ranks_by_match: bool = False
1034    #: The compact round clock, present only for an ``is_awd_match``
1035    #: competition that has a scheduled match, ``None`` otherwise. Lets a
1036    #: list card and the detail header draw "round N / M · mm:ss left"
1037    #: without a per-row ``/awd/match`` fetch. See ``AwdClockBrief``.
1038    #:
1039    #: ⚠️ ``None`` on a match with no ``starts_at`` (unscheduled) as well
1040    #: as on a non-match — the card reads its absence as "no round bar",
1041    #: never as round zero.
1042    awd_clock: AwdClockBrief | None = None
1043    # Whether this board puts a value on individual challenges —
1044    # ``ctfy.server.scoring.prices_challenges`` of this competition's own
1045    # rule.
1046    #
1047    # ⚠️ **An axis, so the wall's scene registry can ask it.** The set of
1048    # scenes a wall rotates through is a property of the *format*, never
1049    # of the data: a panel that appeared the moment somebody solved
1050    # something would re-map every viewer's digit keys mid-event.
1051    #
1052    # ⚠️ **Composed here so no client names rules.** `scoring_rule in
1053    # {"static_points", "dynamic_points"}` is the shape that writes
1054    # itself in a component, and it drops the panel from an AWD+ board —
1055    # whose attack half prices exactly like a points board — and goes
1056    # stale the day a sixth rule is registered.
1057    prices_challenges: bool = False
1058    # Whether every team owes this competition a written engagement
1059    # report — ``core.competitions.expects_report``, the same verdict
1060    # ``CompetitionMembershipInfo.expects_report`` already carries for
1061    # the sidebar.
1062    #
1063    # ⚠️ **Denormalized rather than composed by the client**, even though
1064    # both halves of the disjunction (``silent`` and ``scoring_rule``)
1065    # are already on this model. That is exactly what makes a `||` in a
1066    # component tempting, and it would be the *third* place the rule is
1067    # spelled — after which two clients disagree about whether an event
1068    # has a deliverable. Same argument that put ``is_awd_match`` and
1069    # ``can_replay`` here rather than in a page.
1070    #
1071    # ⚠️ **Needed on the *public* model, not just the membership one**,
1072    # because the sidebar entry it already feeds is membership-gated —
1073    # correctly, since that block is the registered player's nav. So a
1074    # reader deciding whether to enter had no surface anywhere saying
1075    # this event is graded on a written report, which is the one thing
1076    # about the exercise format they most need before they sign up.
1077    #
1078    # Defaults ``False``: reading a missing field as true hangs a report
1079    # card on every Jeopardy competition, which is the claim the
1080    # player-side link waited to be able to make.
1081    expects_report: bool = False
1082    #: Why an invitation to this competition cannot be acted on yet, or
1083    #: ``""`` — the ``core.competitions.invitation_block`` verdict.
1084    #:
1085    #: ⚠️ **The organiser's half of a fact the invitee already gets.**
1086    #: An invitation grants ``access`` and says nothing about ``status``,
1087    #: so one written against a draft is stored, correct and inert. The
1088    #: invitee's inbox says so; the organiser's roster did not, and the
1089    #: roster is the surface where they form a belief about it — every
1090    #: invitee sits at *invited* for ever because nobody **can** register,
1091    #: which reads as a field that is not responding and invites the
1092    #: wrong action (chase them) over the right one (publish).
1093    #:
1094    #: ⚠️ **Composed server-side though ``status`` is on the wire beside
1095    #: it**, for ``expects_report``'s reason: two clients deriving it
1096    #: differently is two clients disagreeing about whether an organiser's
1097    #: invitations are going anywhere. And it is the *same producer* the
1098    #: invitee's card reads, so the two people waiting on each other
1099    #: cannot be told different things — ``AuthoredChallengeInfo.curation``
1100    #: is the precedent, and like that one it is asserted by driving both
1101    #: routes in one test rather than by trusting the shared call.
1102    #:
1103    #: ⚠️ Always ``""`` for anybody who is not an admin here, and that is
1104    #: not a reason to move it to an admin model: a non-admin can only
1105    #: resolve a competition that *is* published, so the empty answer is
1106    #: the true one rather than a withheld one.
1107    invitations_blocked: InvitationBlock = ""
1108    # The competition this one is a *virtual sitting* of, empty for a
1109    # real one.
1110    #
1111    # On the wire because the marker is a rendering decision: the row's
1112    # ``title`` is the source's verbatim, deliberately, so a "(virtual)"
1113    # suffix baked into the database would be untranslatable English in
1114    # every locale's listing. A client reads this and labels it itself.
1115    virtual_of: str = ""
1116    # Whether this contest can be replayed on your own clock — the fact
1117    # a client renders its "Replay" button from, so it does not have to
1118    # POST and read a 400 to find out.
1119    #
1120    # Derived server-side from ``core.virtual.can_replay``, never per
1121    # client, for the same reason ``is_awd_match`` is: a client working
1122    # the refusal set out itself is a second implementation of
1123    # ``plan_virtual_run``, and it would drift once, silently, into a
1124    # button that fails the moment it is pressed.
1125    #
1126    # ⚠️ Deliberately **not** viewer-scoped, unlike ``can_participate``.
1127    # "Am I signed in" the client already knows, and "have I already
1128    # started one" is answered by the route being idempotent — asking it
1129    # here would put a per-user allowlist read inside a projection that
1130    # runs once per row of the listing, which is the N+1 shape three
1131    # other fields on this model have already had to have removed.
1132    # Defaults ``False``: a button that always 400s is worse than none.
1133    can_replay: bool = False
1134    # Total registered teams. Surfaced on cards so users can gauge
1135    # popularity at a glance.
1136    registered_count: int = 0
1137    # Viewer-scoped fields — see class docstring.
1138    is_registered: bool = False
1139    # Whether the viewer may register for this comp. Always ``True`` for
1140    # ``public`` comps; for private comps it's ``True`` only when the
1141    # viewer is invited or can administer it. Lets the frontend render a
1142    # "private — invitation required" locked card without a probe POST.
1143    # Defaults ``True`` so public / anonymous-on-public responses are open.
1144    can_participate: bool = True
1145    my_team_id: str = ""
1146    my_team_name: str = ""
1147    my_role: Literal["captain", "member", ""] = ""
1148    my_rank: int = -1
1149    my_score: int = -1
1150    my_solves: int = -1

Summary row used in the list view.

Per-viewer fields (is_registered / my_*) are populated only when the request carries an authenticated user; for anonymous requests they fall back to sentinels (empty strings, -1) so the response shape stays stable. Front-end treats my_rank < 0 as "no value" rather than "rank zero".

id: str = PydanticUndefined
title: str = PydanticUndefined
description: dict[str, str] = PydanticUndefined
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
created_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
created_by: str = PydanticUndefined
created_by_name: str = PydanticUndefined
challenge_ids: list[str] = PydanticUndefined
status: Literal['draft', 'published', 'archived'] = 'published'
access: Literal['public', 'private_listed', 'private_hidden'] = 'public'
series_id: str = ''
eval_standard: bool = False
corpus_sha: str = ''
ordering: list[BoardCriterion] = PydanticUndefined
agents_allowed: bool = True
authoring_enabled: bool = False
agent_pulse: bool = False
entrants_are_agents: bool = False
authoring_starts_at: datetime.datetime | None = None
authoring_ends_at: datetime.datetime | None = None
authoring_pending_per_team: int = 0
authoring_queue_capacity: int = 0
authoring_terms_version: str = ''
authoring_terms_url: str = ''
registration_starts_at: datetime.datetime | None = None
registration_ends_at: datetime.datetime | None = None
max_team_size: int = 0
registration_review: bool = False
registration_fields: dict[str, typing.Literal['hidden', 'optional', 'required']] = PydanticUndefined
pii_retention_days: int = 0
scoring_rule: Union[Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring'], Literal['']] = ''
scoring_params: dict[str, typing.Any] = PydanticUndefined
silent: bool = False
anonymous_entry: Literal['forbidden', 'optional', 'required'] = 'forbidden'
anonymous_entry_kind: Literal['pseudonymous', 'hidden'] = 'pseudonymous'
scoring_locked: bool = False
phase: Literal['upcoming', 'running', 'past'] = 'running'
sessions: list[CompetitionSessionInfo] = PydanticUndefined
play_open: bool = True
next_session_starts_at: datetime.datetime | None = None
current_session_ends_at: datetime.datetime | None = None
report_window_s: int = 0
report_closes_at: datetime.datetime | None = None
is_awd_match: bool = False
ranks_by_match: bool = False
awd_clock: AwdClockBrief | None = None
prices_challenges: bool = False
expects_report: bool = False
invitations_blocked: Literal['', 'not_open_yet', 'closed'] = ''
virtual_of: str = ''
can_replay: bool = False
registered_count: int = 0
is_registered: bool = False
can_participate: bool = True
my_team_id: str = ''
my_team_name: str = ''
my_role: Literal['captain', 'member', ''] = ''
my_rank: int = -1
my_score: int = -1
my_solves: int = -1
class CompetitionInviteInfo(ctfy.core.models.CtfyModel):
1390class CompetitionInviteInfo(CtfyModel):
1391    """One participation-allowlist entry, for the admin "Invitations"
1392    card on a private competition. The body for POST is the shared
1393    ``GrantCompetitionAdminRequest`` (resolve by id or email).
1394
1395    ⚠️ **An invitation is a funnel, not a fact, and the card used to show
1396    only its first step.** The organiser's question — the one
1397    :class:`~ctfy.core.state.models.CompetitionEmailInviteState`'s own
1398    docstring says the claimed row is kept to answer — is "who did I
1399    invite that never turned up", and being invited, holding an account
1400    and actually registering a team are three different answers. The row
1401    carried the first: a name, a timestamp and a boolean, so somebody who
1402    signed in and then never registered was indistinguishable from
1403    somebody fully in. :attr:`status` is that axis, and the three
1404    ``*_at`` stamps below are the moment each stage was reached.
1405    """
1406
1407    user_id: str
1408    display_name: str = ""
1409    email: str = ""
1410    invited_by: str = ""
1411    #: Resolved from :attr:`invited_by`, because the raw id names nobody.
1412    #: ``""`` when the inviter's account is gone, or when the row was
1413    #: written by the *claim* path, which has no admin to name.
1414    invited_by_name: str = ""
1415    invited_at: datetime | None = None
1416    #: How far this invitation has got. See
1417    #: :data:`~ctfy.core.enums.CompetitionInviteStatus` for why the
1418    #: default is the weakest of the three.
1419    status: CompetitionInviteStatus = "pending_signup"
1420    #: When the invitation found its person — the moment an address with
1421    #: no account was claimed by a *verified* sign-in. Absent for
1422    #: somebody invited by id, who already had an account: there is no
1423    #: such moment for them, and their own registration date is not it.
1424    signed_up_at: datetime | None = None
1425    #: The team they registered with for *this* competition, and when.
1426    #: Empty on any row that has not reached ``registered``.
1427    team_id: str = ""
1428    team_name: str = ""
1429    registered_at: datetime | None = None
1430    #: The organiser's free-text note from when they invited, or ``""``.
1431    #: Shown on the roster so "why is this person on the list" is
1432    #: answerable without leaving the card.
1433    reason: str = ""
1434
1435    @computed_field
1436    @property
1437    def pending_signup(self) -> bool:
1438        """True while the invitation is still waiting for its person: the
1439        address was invited before anyone signed up with it, so there is
1440        no account to name yet. :attr:`user_id` is empty in that state,
1441        which is why the card keys those rows on :attr:`email`.
1442
1443        ⚠️ **Computed from :attr:`status` rather than carried beside it.**
1444        It was a stored field first and the status axis subsumes it, so
1445        the two could disagree — and the client uses this one to pick
1446        which of the two revoke routes to call, keyed differently
1447        (address vs id). A row that said ``pending_signup: false`` while
1448        its status said otherwise would DELETE ``.../invites/`` with an
1449        empty id. A ``computed_field`` because this is a *wire* model, per
1450        the split ``LlmBudgetRow.exhausted`` records.
1451        """
1452        return self.status == "pending_signup"

One participation-allowlist entry, for the admin "Invitations" card on a private competition. The body for POST is the shared GrantCompetitionAdminRequest (resolve by id or email).

⚠️ An invitation is a funnel, not a fact, and the card used to show only its first step. The organiser's question — the one ~ctfy.core.state.models.CompetitionEmailInviteState's own docstring says the claimed row is kept to answer — is "who did I invite that never turned up", and being invited, holding an account and actually registering a team are three different answers. The row carried the first: a name, a timestamp and a boolean, so somebody who signed in and then never registered was indistinguishable from somebody fully in. status is that axis, and the three *_at stamps below are the moment each stage was reached.

user_id: str = PydanticUndefined
display_name: str = ''
email: str = ''
invited_by: str = ''
invited_by_name: str = ''
invited_at: datetime.datetime | None = None
status: Literal['pending_signup', 'signed_up', 'registered'] = 'pending_signup'
signed_up_at: datetime.datetime | None = None
team_id: str = ''
team_name: str = ''
registered_at: datetime.datetime | None = None
reason: str = ''
pending_signup: bool
1435    @computed_field
1436    @property
1437    def pending_signup(self) -> bool:
1438        """True while the invitation is still waiting for its person: the
1439        address was invited before anyone signed up with it, so there is
1440        no account to name yet. :attr:`user_id` is empty in that state,
1441        which is why the card keys those rows on :attr:`email`.
1442
1443        ⚠️ **Computed from :attr:`status` rather than carried beside it.**
1444        It was a stored field first and the status axis subsumes it, so
1445        the two could disagree — and the client uses this one to pick
1446        which of the two revoke routes to call, keyed differently
1447        (address vs id). A row that said ``pending_signup: false`` while
1448        its status said otherwise would DELETE ``.../invites/`` with an
1449        empty id. A ``computed_field`` because this is a *wire* model, per
1450        the split ``LlmBudgetRow.exhausted`` records.
1451        """
1452        return self.status == "pending_signup"

True while the invitation is still waiting for its person: the address was invited before anyone signed up with it, so there is no account to name yet. user_id is empty in that state, which is why the card keys those rows on email.

⚠️ Computed from status rather than carried beside it. It was a stored field first and the status axis subsumes it, so the two could disagree — and the client uses this one to pick which of the two revoke routes to call, keyed differently (address vs id). A row that said pending_signup: false while its status said otherwise would DELETE .../invites/ with an empty id. A computed_field because this is a wire model, per the split LlmBudgetRow.exhausted records.

class CompetitionMembershipInfo(ctfy.core.models.CtfyModel):
109class CompetitionMembershipInfo(CtfyModel):
110    """One row on ``MeResponse.competition_teams``.
111
112    Lets the frontend hydrate a per-comp team picker / "My
113    competitions" hub from a single ``/me`` call without N+1
114    fetches. Captain status surfaces in the UI for buttons gated
115    on ``role == "captain"`` (mint invite / kick / rename).
116
117    ``competition_title`` / ``competition_phase`` are denormalized
118    from the competition the membership points at so the switcher
119    and the dashboard can label rows by the *competition* (the
120    thing the user is choosing between) rather than the team name —
121    every user is auto-joined to an identically named personal team
122    per comp, so team name alone makes the rows indistinguishable.
123    """
124
125    competition_id: str
126    competition_title: str
127    competition_phase: Literal["upcoming", "running", "past"]
128    team_id: str
129    team_name: str
130    role: Literal["captain", "member"]
131    # Whether this competition runs a classic AWD tick loop — the same
132    # fact ``CompetitionInfo.is_awd_match`` carries, denormalized here
133    # for the one reader that cannot fetch it. ``Sidebar`` is mounted
134    # above the routes while ``CompetitionContext`` lives inside
135    # ``CompetitionLayout``, so the nav cannot see the competition the
136    # layout already loaded; it *can* see this, through ``AuthContext``,
137    # with no request of its own. Without it the per-competition block
138    # sends every AWD player to the Jeopardy board, which a match never
139    # populates.
140    is_awd_match: bool = False
141    # Whether this competition runs silent (§4b) — the same fact
142    # ``CompetitionInfo.silent`` carries, denormalized here for the same
143    # reader and the same reason ``is_awd_match`` is: ``Sidebar`` is
144    # mounted above the routes, so it cannot see the competition
145    # ``CompetitionLayout`` already loaded, and it *can* see this through
146    # ``AuthContext`` with no request of its own.
147    #
148    # ⚠️ Without it the per-competition nav offers a Scoreboard entry
149    # for the whole of a silent engagement, and every standing route
150    # behind it refuses — a link that cannot work, for the one format
151    # where "how am I doing" is the question the platform exists to
152    # decline.
153    silent: bool = False
154    # Whether this competition expects an engagement report — the
155    # ``core.competitions.expects_report`` verdict, denormalized here for
156    # the same reader and the same reason the two flags above are.
157    #
158    # ⚠️ **Not the same question as ``silent``**, and reusing that one is
159    # the tempting mistake in both directions: a *loud* exercise
160    # competition grades reports and would get no link, while the
161    # disjunction is what a nav entry is entitled to claim. It is derived
162    # server-side rather than ``||``-ed by each client for the reason
163    # ``is_awd_match`` is: two clients composing it differently is two
164    # clients disagreeing about whether this event has a deliverable.
165    expects_report: bool = False
166    # How many of this team's current write-ups this competition still
167    # owes a verdict on — the cross-competition half of the report
168    # signal, which no other surface could carry.
169    #
170    # ⚠️ **On the membership, not on ``/meta``'s per-caller block.** The
171    # rail draws one tile per competition and every one of its signals
172    # comes off this single payload with no per-tile fetch, so a number
173    # here reaches a player standing anywhere on the platform — including
174    # below ``lg``, where the status bar is ``display: none`` and a chip
175    # there would have reached nobody on a phone. Measured before
176    # choosing: the bar has 167px spare for a member in a competition
177    # (``frontend/e2e/measure-status-bar.spec.ts``), so a chip was
178    # affordable and still wrong.
179    #
180    # ⚠️ **Gated on the competition being *scored*, never on
181    # ``expects_report``.** That one is ``silent OR exercise``, so a
182    # silent ``flag_count`` event expects a report and grades it never —
183    # a badge keyed on it would sit on such a tile for the life of the
184    # event, counting a verdict nobody is coming to give. The same
185    # distinction ``ReportCta`` makes by rendering no chip for
186    # ``not_scored``.
187    #
188    # ⚠️ **A count, and deliberately not a status.** ``waiting`` and
189    # ``queued`` are one fact from here (*they are holding our work*) and
190    # the difference between them is a question the report index answers
191    # with a queue position, one click away. Compressing two states into
192    # one badge is what would make the badge disagree with the page it
193    # links to.
194    #
195    # Defaults ``0``: an older server saying nothing must not paint a
196    # badge on every tile, which is the direction a wrong value is
197    # noticed in least.
198    reports_awaiting_verdict: int = 0
199    # Whether this competition asks entrants to write challenges — the
200    # same fact ``CompetitionInfo.authoring_enabled`` carries,
201    # denormalized here for the same reader and the same reason the
202    # three flags above are: ``Sidebar`` is mounted above the routes and
203    # can only see this through ``AuthContext``.
204    #
205    # ⚠️ Defaults **False**, so an older server offering no opinion
206    # leaves the entry absent. That is the opposite direction from every
207    # *gate* on this model, and it is right for the same reason
208    # ``CompetitionInfo.authoring_enabled`` defaults False: a nav entry
209    # is an offer, and offering one for a Jeopardy event tells its
210    # players there is an authoring surface the competition does not
211    # have.
212    authoring_enabled: bool = False
213    # Whether this competition has a rules page worth a nav entry —
214    # ``bool(CompetitionState.handbook)``, denormalized here for the
215    # same reader and the same reason ``is_awd_match`` is: ``Sidebar``
216    # is mounted above the routes and cannot see ``CompetitionLayout``'s
217    # already-fetched competition.
218    #
219    # ⚠️ **A boolean, never the text.** The handbook is capped at 20 000
220    # characters and ``/me`` carries one of these rows per competition
221    # the caller is in, so publishing the document here would put the
222    # whole set on every page load — ``AuthoredChallengeSummary``'s
223    # recorded defect through a second door.
224    #
225    # ⚠️ Defaults ``False`` for ``authoring_enabled``'s reason: an older
226    # server sends no field, and inventing a rules entry for every
227    # competition points a nav row at a page nobody has written.
228    has_handbook: bool = False
229    # Whether the competition carries any attachment (#1808) — the rules
230    # page's other content, so the nav entry shows for an event whose
231    # rules are only a file. Its own flag rather than folded into
232    # ``has_handbook``: a flag named for the wrong fact is shape 4.
233    # Defaults ``False`` for ``has_handbook``'s reason.
234    has_attachments: bool = False
235    # ⚠️ **The authoring *window* is deliberately not here.** The rail's
236    # only consumer is the nav entry, which must stay visible after the
237    # window closes — a verdict is read long after the last upload — so
238    # nothing would look at it, and `/me`'s producer would have to
239    # remember to fill two more fields per membership. Unset it is a
240    # claim ("this event has no window") on every competition that does.
241    # `CompetitionInfo` carries the pair, and the authoring page reads it
242    # off the competition it is already given.
243    # Rail-enrichment fields (denormalized so the Slack-style rail can
244    # render instance count / rank / a time-progress ring from one /me
245    # call, no per-tile fetch fan-out).
246    #
247    # Live instances for THIS team in THIS comp. Mirrors
248    # InstanceCountContext semantics (all non-terminal InstanceState
249    # rows for the team — terminal ones already left the live store as
250    # InstanceRecord); 0 when none.
251    running_instances: int = 0
252    # Competition window, copied straight off the CompetitionState the
253    # /me loop already fetches. "" start = live immediately, "" end =
254    # never ends (same convention as CompetitionState / phase()).
255    starts_at: datetime | None = None
256    ends_at: datetime | None = None
257    # The two facts a *pure rounds match* is read by — the same pair
258    # ``CompetitionInfo`` carries, from the same producers
259    # (``core.competitions.ranks_by_match`` and ``awd_clock_brief``).
260    #
261    # ⚠️ **The window above is not the event for such a competition.** The
262    # runbook's exhibition has no window of its own, so ``competition_
263    # phase`` is ``running`` from creation on; the rail read that and put
264    # a running pie on a tile three hours before kickoff, while the page
265    # it links to said 即将开始. The browser narrows the window to the
266    # match's (``frontend/src/lib/headlineWindow.ts``) — which it can only
267    # do if the kickoff and the last round's close reach it.
268    #
269    # ⚠️ Default ``False`` / ``None``: an older server sends neither, and
270    # the tile then reads the competition's own window exactly as it
271    # always did — the reading that claims nothing new.
272    ranks_by_match: bool = False
273    awd_clock: AwdClockBrief | None = None
274    # Caller's current rank in this comp. -1 sentinel = "not computed"
275    # (upcoming phase, or the per-request rank budget was exhausted) —
276    # matches the existing my_rank<0 frontend convention.
277    my_rank: int = -1
278    # Registered-team count, the denominator the UI shows as "#3 / 40".
279    competition_team_count: int = 0

One row on MeResponse.competition_teams.

Lets the frontend hydrate a per-comp team picker / "My competitions" hub from a single /me call without N+1 fetches. Captain status surfaces in the UI for buttons gated on role == "captain" (mint invite / kick / rename).

competition_title / competition_phase are denormalized from the competition the membership points at so the switcher and the dashboard can label rows by the competition (the thing the user is choosing between) rather than the team name — every user is auto-joined to an identically named personal team per comp, so team name alone makes the rows indistinguishable.

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_phase: Literal['upcoming', 'running', 'past'] = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
role: Literal['captain', 'member'] = PydanticUndefined
is_awd_match: bool = False
silent: bool = False
expects_report: bool = False
reports_awaiting_verdict: int = 0
authoring_enabled: bool = False
has_handbook: bool = False
has_attachments: bool = False
running_instances: int = 0
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
ranks_by_match: bool = False
awd_clock: AwdClockBrief | None = None
my_rank: int = -1
competition_team_count: int = 0
class CompetitionRecords(ctfy.core.models.CtfyModel):
100class CompetitionRecords(CtfyModel):
101    """Everything a scoring script needs, in one file.
102
103    Lists are complete: an export is the whole event or it is not one.
104    """
105
106    competition_id: str
107    title: str = ""
108    scoring_rule: str = ""
109    exported_at: datetime
110    challenge_ids: list[str] = Field(default_factory=list)
111    teams: list[RecordTeam] = Field(default_factory=list)
112    solves: list[RecordSolve] = Field(default_factory=list)
113    submissions: list[RecordSubmission] = Field(default_factory=list)
114    eval_runs: list[RecordEvalRun] = Field(default_factory=list)
115    authored_challenges: list[RecordAuthoredChallenge] = Field(default_factory=list)

Everything a scoring script needs, in one file.

Lists are complete: an export is the whole event or it is not one.

competition_id: str = PydanticUndefined
title: str = ''
scoring_rule: str = ''
exported_at: datetime.datetime = PydanticUndefined
challenge_ids: list[str] = PydanticUndefined
teams: list[RecordTeam] = PydanticUndefined
solves: list[RecordSolve] = PydanticUndefined
submissions: list[RecordSubmission] = PydanticUndefined
eval_runs: list[RecordEvalRun] = PydanticUndefined
authored_challenges: list[RecordAuthoredChallenge] = PydanticUndefined
class CompetitionRegistrationInfo(ctfy.core.models.CtfyModel):
1319class CompetitionRegistrationInfo(CtfyModel):
1320    """One row in the admin "who's registered" table."""
1321
1322    team_id: str
1323    team_name: str = ""
1324    registered_at: datetime | None = None

One row in the admin "who's registered" table.

team_id: str = PydanticUndefined
team_name: str = ''
registered_at: datetime.datetime | None = None
class CompetitionRoster(ctfy.core.models.CtfyModel):
161class CompetitionRoster(CtfyModel):
162    """``GET /competitions/{id}/roster`` — every registered team with its
163    members, in one read.
164
165    Exists because the alternative is a fan-out: a caller that wants to
166    know who is playing has to call ``GET /teams/{id}`` once per team,
167    which for a 400-team event is 400 round trips for a page that has to
168    stay live. The backend answers it in three bulk queries whatever the
169    team count.
170
171    ⚠️ **Public identity only.** ``TeamMemberInfo`` carries
172    ``display_name`` / ``avatar_url`` and nothing else, which is exactly
173    what ``GET /teams/{id}`` already publishes per team. The entrant
174    dossier — ``real_name`` / ``phone`` / ``mailing_address``, 个人敏感信息
175    sealed at the backend boundary — reaches the reviewer roster
176    (``RegistrationTeamRow``) and nowhere else, and must never be added
177    here: this response is public and cacheable, so a field added to it
178    is a field published to everyone forever.
179    """
180
181    teams: list[CompetitionRosterTeam] = Field(default_factory=list)
182    generated_at: float = 0.0

GET /competitions/{id}/roster — every registered team with its members, in one read.

Exists because the alternative is a fan-out: a caller that wants to know who is playing has to call GET /teams/{id} once per team, which for a 400-team event is 400 round trips for a page that has to stay live. The backend answers it in three bulk queries whatever the team count.

⚠️ Public identity only. TeamMemberInfo carries display_name / avatar_url and nothing else, which is exactly what GET /teams/{id} already publishes per team. The entrant dossier — real_name / phone / mailing_address, 个人敏感信息 sealed at the backend boundary — reaches the reviewer roster (RegistrationTeamRow) and nowhere else, and must never be added here: this response is public and cacheable, so a field added to it is a field published to everyone forever.

teams: list[CompetitionRosterTeam] = PydanticUndefined
generated_at: float = 0.0
class CompetitionRosterTeam(ctfy.core.models.CtfyModel):
110class CompetitionRosterTeam(CtfyModel):
111    """One team plus its roster, for the competition-wide roster read."""
112
113    team_id: str
114    name: str = ""
115    captain_user_id: str = ""
116    members: list[TeamMemberInfo] = Field(default_factory=list)
117    #: See :attr:`ctfy.server.models.ScoreboardEntry.anonymous`.
118    anonymous: bool = False
119    #: The squad's institution badge — see :attr:`TeamInfo.affiliation`,
120    #: which is the same fact about the same subject.
121    affiliation: AffiliationInfo | None = None

One team plus its roster, for the competition-wide roster read.

team_id: str = PydanticUndefined
name: str = ''
captain_user_id: str = ''
members: list[TeamMemberInfo] = PydanticUndefined
anonymous: bool = False
affiliation: AffiliationInfo | None = None
class CompetitionScoreDistribution(ctfy.core.models.CtfyModel):
1359class CompetitionScoreDistribution(CtfyModel):
1360    """Aggregate companion to the (now server-paginated) competition
1361    scoreboard: the score-distribution histogram + the true team count.
1362
1363    The standings table pages server-side, so the histogram / "N teams"
1364    figure can't be re-derived from one page in the browser — this
1365    bins every registered team's ``flags_solved`` with the same
1366    adaptive scheme the frontend used to do client-side.
1367    """
1368
1369    total_teams: int = 0
1370    bins: list[ScoreBucket] = Field(default_factory=list)

Aggregate companion to the (now server-paginated) competition scoreboard: the score-distribution histogram + the true team count.

The standings table pages server-side, so the histogram / "N teams" figure can't be re-derived from one page in the browser — this bins every registered team's flags_solved with the same adaptive scheme the frontend used to do client-side.

total_teams: int = 0
bins: list[ScoreBucket] = PydanticUndefined
class CompetitionScoreHistory(ctfy.core.models.CtfyModel):
1263class CompetitionScoreHistory(CtfyModel):
1264    """Top-N teams' score/rank progression, reconstructed from the
1265    competition's solve log (no snapshot dependency — exact for past
1266    and live competitions alike)."""
1267
1268    series: list[ScoreHistorySeries] = Field(default_factory=list)
1269    generated_at: float = 0.0

Top-N teams' score/rank progression, reconstructed from the competition's solve log (no snapshot dependency — exact for past and live competitions alike).

series: list[ScoreHistorySeries] = PydanticUndefined
generated_at: float = 0.0
class CompetitionSeriesConfig(ctfy.core.models.CtfyModel):
35class CompetitionSeriesConfig(CtfyModel):
36    """What an organiser sets on a series."""
37
38    model_config = ConfigDict(extra="forbid")
39
40    name: str = ""
41    enabled: bool = False
42
43    weekday: int = 5
44    hour: int = 20
45    minute: int = 0
46    timezone: str = "UTC"
47    every_n_weeks: int = 1
48    first_at: AwareDatetime | None = None
49    lead_seconds: int = Field(default=172800, ge=0)
50
51    title_template: str = ""
52    description: str = ""
53    duration_seconds: int = Field(default=10800, ge=1)
54    pool: list[str] = Field(default_factory=list)
55    slate_size: int = Field(default=1, ge=1)
56    #: ⚠️ Typed like the two axes directly below it. It was the one
57    #: field on this model that was not, which is how a series could
58    #: spawn every occurrence under a rule the board cannot run.
59    scoring_rule: ScoringRuleName | Literal[""] = ""
60    scoring_params: dict[str, Any] = Field(default_factory=dict)
61    status: CompetitionStatus = "published"
62    access: CompetitionAccess = "public"
63    #: Seconds after each occurrence's ``ends_at`` that its report stays
64    #: open. ⚠️ Without it an ``exercise`` series was unofferable — every
65    #: occurrence took the platform default of ``0``, which means the
66    #: report closes with the event.
67    report_window_s: int = Field(default=0, ge=0)
68
69    @model_validator(mode="after")
70    def _cadence_is_expressible(self) -> CompetitionSeriesConfig:
71        """⚠️ Validated through `Cadence`, never re-checked here.
72
73        Two copies of "is this a legal weekday / a real timezone" is how
74        the form and the job come to disagree about which Saturdays
75        exist — and the job's copy is the one nobody sees fail.
76        """
77        Cadence(
78            weekday=self.weekday,
79            hour=self.hour,
80            minute=self.minute,
81            timezone=self.timezone,
82            every_n_weeks=self.every_n_weeks,
83        )
84        return self

What an organiser sets on a series.

name: str = ''
enabled: bool = False
weekday: int = 5
hour: int = 20
minute: int = 0
timezone: str = 'UTC'
every_n_weeks: int = 1
first_at: pydantic.types.AwareDatetime | None = None
lead_seconds: int = 172800
title_template: str = ''
description: str = ''
duration_seconds: int = 10800
pool: list[str] = PydanticUndefined
slate_size: int = 1
scoring_rule: Union[Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring'], Literal['']] = ''
scoring_params: dict[str, typing.Any] = PydanticUndefined
status: Literal['draft', 'published', 'archived'] = 'published'
access: Literal['public', 'private_listed', 'private_hidden'] = 'public'
report_window_s: int = 0
class CompetitionSeriesInfo(ctfy.server.models.CompetitionSeriesConfig):
 87class CompetitionSeriesInfo(CompetitionSeriesConfig):
 88    """A series as the organiser's page reads it."""
 89
 90    id: str = ""
 91    created_at: AwareDatetime | None = None
 92    created_by: str = ""
 93    #: The next scheduled start, or ``None`` for a series whose schedule
 94    #: is over or unusable. Derived, never stored.
 95    next_occurrence_at: AwareDatetime | None = None
 96    #: How many occurrences already exist. A count is the right shape
 97    #: *here* — this is a read for a human, not the job's idempotency,
 98    #: which asks the database which instants exist.
 99    occurrences_spawned: int = 0
100    #: ⚠️ Whether the job that actually spawns is switched on. It ships
101    #: enabled, unlike `awd_tick`, but a super-admin can pause it from
102    #: `/admin/scheduled-jobs` — and a series page that cannot say so
103    #: leaves an organiser reading a perfectly configured schedule that
104    #: will never run. Absent means not seeded, which is the same thing
105    #: as off to a reader; reporting it as on is the false reassurance
106    #: this field exists to remove.
107    spawn_job_enabled: bool = False
108    #: How many of `pool` declare a patchable surface.
109    #:
110    #: ⚠️ Derived here rather than in each client, which is the opposite
111    #: of how the competition wizard answers the same question — and the
112    #: difference is not an inconsistency. That form has just *browsed*
113    #: the catalog, so it holds the rows; a series client holds ids and
114    #: nothing else, and there is no by-id read on `GET /challenges`. Two
115    #: clients deriving it would be two clients disagreeing about whether
116    #: a weekly can score defence at all.
117    #:
118    #: The reason it is worth a field: `scoring_rule` and `pool` are set
119    #: in one form and nothing cross-checks them, so an "attack &
120    #: defence" weekly over a pool with nothing patchable scores zero
121    #: defence **every week**, unattended, with no error anywhere. This
122    #: is a report, never a refusal — a pool an organiser intends to fill
123    #: later is legitimate.
124    defensible_pool_count: int = 0
125    #: Pool entries the catalog does not have. A separate and equally
126    #: silent failure: `plan_slate` refuses an occurrence whose slate it
127    #: cannot fill, so a pool naming a since-deleted challenge produces a
128    #: Saturday with no contest rather than an error.
129    unknown_pool_ids: list[str] = Field(default_factory=list)
130    #: Pool entries **another series** also draws from — the third member
131    #: of the same family, and the one with a security edge rather than a
132    #: scoring one.
133    #:
134    #: `plan_slate` consumes a pool *within one series*, so two series
135    #: overlapping is invisible from either: nothing refuses, nothing
136    #: logs, and both weeklies are individually correct. What it costs is
137    #: that a challenge played on the internal ladder is **pre-leaked**
138    #: when the public one draws it, and the first sign is a public
139    #: Saturday where some squad solves in ninety seconds.
140    #:
141    #: A report, never a refusal, exactly like the two above: an
142    #: organiser staging both series out of one draft pool, or
143    #: deliberately re-running a problem for a different audience, is
144    #: doing something legitimate.
145    shared_pool_ids: list[str] = Field(default_factory=list)

A series as the organiser's page reads it.

id: str = ''
created_at: pydantic.types.AwareDatetime | None = None
created_by: str = ''
next_occurrence_at: pydantic.types.AwareDatetime | None = None
occurrences_spawned: int = 0
spawn_job_enabled: bool = False
defensible_pool_count: int = 0
unknown_pool_ids: list[str] = PydanticUndefined
shared_pool_ids: list[str] = PydanticUndefined
class CompetitionSessionInfo(ctfy.core.models.CtfyModel):
302class CompetitionSessionInfo(CtfyModel):
303    """One playable slice of a competition's window, on the wire."""
304
305    starts_at: datetime
306    ends_at: datetime

One playable slice of a competition's window, on the wire.

starts_at: datetime.datetime = PydanticUndefined
ends_at: datetime.datetime = PydanticUndefined
class CompetitionUpdate(ctfy.core.models.CtfyModel):
581class CompetitionUpdate(CtfyModel):
582    """PATCH body — every field optional, ``None`` means "leave alone"."""
583
584    title: str | None = Field(default=None, min_length=1, max_length=200)
585    description: LocalizedText | None = None
586    #: ``None`` leaves the handbook alone; ``{}`` clears it. The route
587    #: stamps ``handbook_updated_at`` only when the text really differs,
588    #: so re-saving the form does not move that date.
589    #:
590    #: ⚠️ **The map is replaced, never merged.** Every other field on
591    #: this endpoint replaces, and a merge would make clearing one
592    #: locale impossible — a client that sends only ``zh-CN`` means
593    #: "these are the translations now". The CLI reads the stored map
594    #: and sends the merged result, which is where a per-locale flag
595    #: belongs.
596    handbook: LocalizedText | None = None
597
598    _v_description = field_validator("description", mode="before")(
599        lambda v: localized_field(v, cap=MAX_DESCRIPTION_CHARS, field="description")
600    )
601    _v_handbook = field_validator("handbook", mode="before")(
602        lambda v: localized_field(v, cap=MAX_HANDBOOK_CHARS, field="handbook")
603    )
604    starts_at: datetime | None = None
605    ends_at: datetime | None = None
606    challenge_ids: list[str] | None = Field(default=None)  # no cap — see CompetitionCreate
607    status: CompetitionStatus | None = None
608    access: CompetitionAccess | None = None
609    eval_standard: bool | None = None
610    corpus_sha: str | None = None
611    # ⚠️ ``None`` means untouched, like every field here. A handler
612    # reading truthiness instead could only ever turn this *on*, so an
613    # organiser's opt-out would be undone by the next unrelated save.
614    agents_allowed: bool | None = None
615    # ⚠️ ``None`` means untouched, for the reason directly above.
616    authoring_enabled: bool | None = None
617    # ⚠️ ``None`` means untouched, for the reason directly above: the
618    # edit form re-sends every field, so a plain ``False`` default would
619    # turn every unrelated save into a wall panel being switched off.
620    agent_pulse: bool | None = None
621    # ⚠️ ``None`` means untouched, for the reason directly above: the
622    # edit form re-sends every field, so a plain ``False`` default would
623    # re-open human registration on every unrelated save of an
624    # exhibition. See CompetitionState.entrants_are_agents.
625    entrants_are_agents: bool | None = None
626    # The authoring window, distinct from both the play window and the
627    # registration one; unset on both ends means "for as long as the
628    # event runs". `core.competitions.authoring_phase` is the rule.
629    authoring_starts_at: datetime | None = None
630    authoring_ends_at: datetime | None = None
631    # ⚠️ ``None`` means untouched, like every field here, and ``0`` is a
632    # real value on both: the way back to the platform's per-team cap,
633    # and to no capacity. See CompetitionState.authoring_pending_per_team
634    # / authoring_queue_capacity.
635    authoring_pending_per_team: int | None = Field(default=None, ge=0)
636    authoring_queue_capacity: int | None = Field(default=None, ge=0)
637    # ``""`` is a real value on both — how an organiser stops asking for
638    # an agreement — and the pair is checked against the state the patch
639    # *leaves*, so clearing one alone is refused.
640    authoring_terms_version: str | None = Field(default=None, max_length=64)
641    authoring_terms_url: WebUrl | None = Field(default=None, max_length=500)
642    # Rejected with 409 once the competition is live — re-submitting the
643    # value it already holds is a no-op, so the edit form can keep
644    # sending every field when the admin saves any of them.
645    #
646    # ⚠️ Typed like ``CompetitionCreate``'s, and the reason is that they
647    # are two doors onto one field. Narrowing only create left an unknown
648    # rule refused by pydantic there and by ``_check_scoring``'s
649    # ``build_rule`` here — same 422, two different bodies, for the same
650    # mistake. Three states, all real: ``None`` leaves it alone, ``""``
651    # selects the platform default, a member names a rule.
652    scoring_rule: ScoringRuleName | Literal[""] | None = None
653    scoring_params: dict[str, Any] | None = None
654    # ⚠️ Optional like every field here: ``None`` means untouched.
655    # Flipping silence mid-event is a real organiser action (a range run
656    # loud for a practice round, then silent for the graded one), so it
657    # is deliberately *not* frozen the way ``scoring_rule`` is — silence
658    # changes what the player is told from here on, it does not re-rank a
659    # board they have already seen.
660    silent: bool | None = None
661    #: ``None`` leaves the schedule alone; ``[]`` clears every session and
662    #: returns the competition to a single continuous window.
663    sessions: list[CompetitionSessionInfo] | None = None
664    # ⚠️ Optional like every field here: ``None`` means untouched. Not
665    # frozen the way ``scoring_rule`` is — extending the report period on
666    # the day is ordinary event work, and it re-ranks nothing.
667    report_window_s: int | None = None
668    # ⚠️ ``None`` means untouched, like every field here. **Widening is
669    # free; narrowing is refused once anybody has relied on the old
670    # setting** (``anonymity_promised``, 409) — an organiser flipping
671    # ``optional`` back to ``forbidden`` would publish every entrant who
672    # asked to be withheld, in one click, permanently, with nothing on
673    # the page to say so. The *kind* is free in both directions: it
674    # decides whether a withheld row is renamed or removed, and neither
675    # answer publishes a name.
676    anonymous_entry: AnonymityPolicy | None = None
677    anonymous_entry_kind: AnonymityKind | None = None
678    # --- registration ---------------------------------------------------
679    # The window sign-ups are open, distinct from the play window above.
680    # ``CompetitionState.registration_starts_at`` is the derivation.
681    registration_starts_at: datetime | None = None
682    registration_ends_at: datetime | None = None
683    # Per-competition roster cap. 0 defers to the platform-wide setting,
684    # so two events on one deployment can carry different rules.
685    max_team_size: int | None = Field(default=None, ge=0)
686    # When true a newly registered team lands ``pending`` and cannot play
687    # until an organiser approves it.
688    registration_review: bool | None = None
689    # field name -> policy; anything absent is treated as hidden.
690    # ``CompetitionState.registration_fields`` is the derivation. ⚠️ The
691    # ``None`` here is this model's partial-update sentinel — *leave the
692    # map alone* — and is not a third policy value.
693    registration_fields: dict[str, RegistrationFieldPolicy] | None = None
694    #: ``None`` leaves it alone, same as every other field here.
695    pii_retention_days: int | None = Field(default=None, ge=0)
696
697    _norm_times = field_validator(
698        "starts_at",
699        "ends_at",
700        "registration_starts_at",
701        "registration_ends_at",
702        # ⚠️ The authoring window travels the same road: an admin form
703        # sends "" for a cleared date, and without this it is a 422
704        # rather than "leave it unset".
705        "authoring_starts_at",
706        "authoring_ends_at",
707        mode="before",
708    )(_blank_to_none)
709
710    @field_validator("registration_fields")
711    @classmethod
712    def _known_registration_fields(
713        cls, v: dict[str, RegistrationFieldPolicy] | None
714    ) -> dict[str, RegistrationFieldPolicy] | None:
715        return None if v is None else _check_registration_fields(v)

PATCH body — every field optional, None means "leave alone".

title: str | None = None
description: dict[str, str] | None = None
handbook: dict[str, str] | None = None
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
challenge_ids: list[str] | None = None
status: Optional[Literal['draft', 'published', 'archived']] = None
access: Optional[Literal['public', 'private_listed', 'private_hidden']] = None
eval_standard: bool | None = None
corpus_sha: str | None = None
agents_allowed: bool | None = None
authoring_enabled: bool | None = None
agent_pulse: bool | None = None
entrants_are_agents: bool | None = None
authoring_starts_at: datetime.datetime | None = None
authoring_ends_at: datetime.datetime | None = None
authoring_pending_per_team: int | None = None
authoring_queue_capacity: int | None = None
authoring_terms_version: str | None = None
authoring_terms_url: Optional[Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x72ea04d2a980>)]] = None
scoring_rule: Union[Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring'], Literal[''], NoneType] = None
scoring_params: dict[str, typing.Any] | None = None
silent: bool | None = None
sessions: list[CompetitionSessionInfo] | None = None
report_window_s: int | None = None
anonymous_entry: Optional[Literal['forbidden', 'optional', 'required']] = None
anonymous_entry_kind: Optional[Literal['pseudonymous', 'hidden']] = None
registration_starts_at: datetime.datetime | None = None
registration_ends_at: datetime.datetime | None = None
max_team_size: int | None = None
registration_review: bool | None = None
registration_fields: dict[str, typing.Literal['hidden', 'optional', 'required']] | None = None
pii_retention_days: int | None = None
class ContributorRow(ctfy.core.models.CtfyModel):
 995class ContributorRow(CtfyModel):
 996    """One author named on at least one published upload (plan P2.5).
 997
 998    ⚠️ **No points for uploading, so no ranking either.** The numbers are
 999    what players did with the challenges — the reception — and the list
1000    is ordered by name: a roll of who wrote the catalog, never a board
1001    the review queue would then be gamed for.
1002    """
1003
1004    user_id: str
1005    author_display_name: str
1006    #: Published uploads naming this person that this viewer may read.
1007    challenges: int
1008    #: Finishes summed over those challenges. ⚠️ ``None`` when any one of
1009    #: them has its tallies withheld: a partial sum is a claim about the
1010    #: whole, the absent-split-is-not-a-zero-split trap.
1011    solves: int | None = None
1012    #: Each reaction summed over the same challenges, largest first —
1013    #: per kind and never one total, since a report of a defect is not
1014    #: praise. ``None`` when withheld, for either of the row's reasons.
1015    reactions: list[ReactionCount] | None = None

One author named on at least one published upload (plan P2.5).

⚠️ No points for uploading, so no ranking either. The numbers are what players did with the challenges — the reception — and the list is ordered by name: a roll of who wrote the catalog, never a board the review queue would then be gamed for.

user_id: str = PydanticUndefined
author_display_name: str = PydanticUndefined
challenges: int = PydanticUndefined
solves: int | None = None
reactions: list[ReactionCount] | None = None
class CreateFineGrainedTokenRequest(ctfy.core.models.CtfyModel):
367class CreateFineGrainedTokenRequest(CtfyModel):
368    """Body for ``POST /auth/tokens`` (mints a fine-grained token).
369
370    Back-compat: when ``competition_access`` / ``permissions`` are
371    omitted the server mints the broad legacy profile (every
372    competition, full participate access) so existing
373    ``mint_token(label)`` callers — SDK, CLI, MCP — keep getting a
374    token equivalent to the old agent token.
375    """
376
377    label: str = Field(default="", max_length=64)
378    # Lifetime in days. ``None`` uses the server default (30); ``0`` means
379    # "never expires". The server caps this at ~5 years to reject absurd
380    # values.
381    expires_in_days: int | None = Field(default=None, ge=0, le=365 * 5)
382    #: Start from a named template (``GET /auth/tokens/templates``).
383    #:
384    #: ⚠️ **A starting point the explicit fields override, never a
385    #: fourth way to say the same thing.** ``competition_access`` /
386    #: ``permissions`` / ``expires_in_days`` win where the caller sent
387    #: them, so a client can take ``agent_play`` and narrow it to two
388    #: competitions in one request — which is the flow the wizard walks
389    #: and would otherwise need two.
390    #:
391    #: ⚠️ Typed, unlike ``permissions`` one field down, and for the
392    #: opposite reason: there is no clamp for a template name. An
393    #: unrecognised one has no safe reading — falling back to the broad
394    #: default would mint a *wider* token than was asked for — so the
395    #: refusal is the feature and pydantic is the cheapest place to
396    #: state it.
397    template: TokenTemplateName | None = None
398    # ``None`` → legacy-broad default. Otherwise: none | all | selected.
399    competition_access: str | None = None
400    competition_ids: list[str] = Field(default_factory=list)
401    # Category → a ``PermissionLevel``. ⚠️ **Typed ``str`` on purpose**,
402    # unlike every other axis in this file: this is a *request*, and
403    # ``clamp_permissions`` exists to lower whatever arrives rather than
404    # refuse it. Narrowing the annotation would turn a clamp into a 422
405    # and break the tolerance the clamp was written for. ``None`` →
406    # legacy-broad default.
407    permissions: dict[str, str] | None = None

Body for POST /auth/tokens (mints a fine-grained token).

Back-compat: when competition_access / permissions are omitted the server mints the broad legacy profile (every competition, full participate access) so existing mint_token(label) callers — SDK, CLI, MCP — keep getting a token equivalent to the old agent token.

label: str = ''
expires_in_days: int | None = None
template: Optional[Literal['agent_play', 'agent_author', 'ci_submit', 'read_only', 'registry_push', 'full_access']] = None
competition_access: str | None = None
competition_ids: list[str] = PydanticUndefined
permissions: dict[str, str] | None = None
class CreateFineGrainedTokenResponse(ctfy.server.models.TokenInfo):
410class CreateFineGrainedTokenResponse(TokenInfo):
411    """Includes the plaintext ``token`` — returned once, never persisted."""
412
413    token: str

Includes the plaintext token — returned once, never persisted.

token: str = PydanticUndefined
class CreateInviteRequest(ctfy.core.models.CtfyModel):
489class CreateInviteRequest(CtfyModel):
490    """Body for ``POST /api/v1/nodes/invites``.
491
492    ``ttl_seconds`` controls how long the minted registration token stays
493    valid — it cannot be renewed, only re-created.
494    """
495
496    ttl_seconds: int = Field(default=3600, ge=60, le=7 * 24 * 3600)
497    # Optional vendor binding: a node registered with this invite is stamped
498    # as that vendor's bring-your-own-node (docs/model-eval-vendor-tenancy.md
499    # §6.3). "" = a platform-shared node (the default). Validated to an
500    # existing vendor at mint time.
501    owner_vendor_id: str = ""

Body for POST /api/v1/nodes/invites.

ttl_seconds controls how long the minted registration token stays valid — it cannot be renewed, only re-created.

ttl_seconds: int = 3600
owner_vendor_id: str = ''
class CreateInviteResponse(ctfy.core.models.CtfyModel):
504class CreateInviteResponse(CtfyModel):
505    """Plaintext registration token is returned **exactly once** here.
506
507    Subsequent ``GET /nodes/invites`` lookups only expose metadata; the
508    plaintext is never persisted — only ``token_hash`` is stored.
509    """
510
511    id: str
512    registration_token: str
513    expires_at: datetime | None = None

Plaintext registration token is returned exactly once here.

Subsequent GET /nodes/invites lookups only expose metadata; the plaintext is never persisted — only token_hash is stored.

id: str = PydanticUndefined
registration_token: str = PydanticUndefined
expires_at: datetime.datetime | None = None
class CreditedChallengeRow(ctfy.core.models.CtfyModel):
964class CreditedChallengeRow(CtfyModel):
965    """One published upload whose author asked to be named (plan P2.5).
966
967    Only an upload in the catalog, credited ``display_name``, and not
968    withheld from this viewer by the anonymity of the event it came from
969    (`ctfy.server.anonymity.anonymise_author_credits`) is a row at all.
970    """
971
972    id: str
973    name: str
974    category: ChallengeCategory = ChallengeCategory.WEB
975    difficulty: str = ""
976    author_user_id: str
977    author_display_name: str
978    #: Entrants (a team in one competition) that finished every question,
979    #: across every event the challenge is played in — never under one
980    #: scoring rule. ⚠️ ``None`` while a live silent exercise curating it
981    #: withholds its tallies, **emptied, never zeroed**: a ``0`` there
982    #: would be a claim that nobody has finished it.
983    solves: int | None = None
984    #: When the first entrant finished it; ``None`` when nobody has, or
985    #: when the tallies are withheld (then ``solves`` is ``None`` too).
986    first_solved_at: AwareDatetime | None = None
987    #: What players said, one entry per reaction given, largest first.
988    #: ``None`` when withheld: for ``solves``' reason, or because the
989    #: viewer may not read reaction counts at all — those are a signed-in
990    #: read (``submissions:read``, `routes.feedback.feedback_stats`), and a
991    #: page a guest can open must not be the door around that.
992    reactions: list[ReactionCount] | None = None

One published upload whose author asked to be named (plan P2.5).

Only an upload in the catalog, credited display_name, and not withheld from this viewer by the anonymity of the event it came from (ctfy.server.anonymity.anonymise_author_credits) is a row at all.

id: str = PydanticUndefined
name: str = PydanticUndefined
category: ctfy.challenge.benchmark.ChallengeCategory = <ChallengeCategory.WEB: 'web'>
difficulty: str = ''
author_user_id: str = PydanticUndefined
author_display_name: str = PydanticUndefined
solves: int | None = None
first_solved_at: pydantic.types.AwareDatetime | None = None
reactions: list[ReactionCount] | None = None
class DeceptionStats(ctfy.core.models.CtfyModel):
1153class DeceptionStats(CtfyModel):
1154    """Three states, of which only two are counted.
1155
1156    ``contacted`` and ``complied`` are each a stored count of crossings;
1157    ``resisted`` is the difference. There is no third counter because
1158    there is no moment at which a refusal happens for something to
1159    count — resistance is the absence of compliance after contact, and
1160    an absence cannot be recorded at the time it fails to occur.
1161    """
1162
1163    contacted: int = 0
1164    complied: int = 0
1165    resisted: int = 0
1166    #: ``complied / contacted``. Absent rather than zero when nothing has
1167    #: been contacted: 0/0 is "no data", and rendering it as a 0% rate
1168    #: would assert that no agent was fooled, which is a claim the
1169    #: platform has not earned.
1170    comply_rate: float | None = None
1171    #: ⚠️ Rides on the wire so a reader cannot get the number without it.
1172    caveat: str = (
1173        "resisted is an upper bound: the probe runs inside a container the "
1174        "player can compromise, so a missing compliance record is not "
1175        "evidence of restraint."
1176    )

Three states, of which only two are counted.

contacted and complied are each a stored count of crossings; resisted is the difference. There is no third counter because there is no moment at which a refusal happens for something to count — resistance is the absence of compliance after contact, and an absence cannot be recorded at the time it fails to occur.

contacted: int = 0
complied: int = 0
resisted: int = 0
comply_rate: float | None = None
caveat: str = 'resisted is an upper bound: the probe runs inside a container the player can compromise, so a missing compliance record is not evidence of restraint.'
class DefenceInfo(ctfy.core.models.CtfyModel):
378class DefenceInfo(CtfyModel):
379    """What a defender may do on this challenge — AWD+ only.
380
381    Present iff the challenge declares a ``patch:`` block. ``None``
382    everywhere else, which is what lets a client decide whether to render
383    a defence surface at all: without this the only way to find out was
384    to POST a submission and read the refusal, so the web UI had no way
385    to know AWD+ existed.
386
387    ``targets`` is deliberately public. It is a whitelist, not a secret —
388    a defender is *given* this source (they can read it in their own
389    box), the refusal message already names a rejected path, and a UI
390    that cannot say which files it will accept sends players guessing.
391    """
392
393    #: Challenge-relative paths a submission may replace. Anything else
394    #: is refused at submit time rather than at judge time.
395    targets: list[str] = Field(default_factory=list)
396    #: Whether ``patch.live`` declares a defender's shell, i.e. whether
397    #: ``ctfy patch shell`` / the terminal button will work here. False
398    #: means uploads are the only channel — a real and supported shape,
399    #: not a degraded one, so the UI says so rather than hiding.
400    live_shell: bool = False
401    #: The compose service the defender's shell opens in
402    #: (``patch.live.service``); ``""`` without a shell.
403    shell_service: str = ""
404    #: Whether that service is one the round loop **scores** — attacks and
405    #: probes. ⚠️ **False is a different box, not a smaller one**: a shell
406    #: in a sidecar that merely shares the source cannot restart, signal
407    #: or even reach the scored process, and a brief that said otherwise
408    #: sent agents hunting for a process that was not there.
409    shell_is_scored: bool = False
410    #: The command, run in the shell, that makes an edit take effect
411    #: (``patch.live.restart``). ``""`` = the challenge does not say —
412    #: *unknown*, never "edits reload themselves".
413    restart_command: str = ""
414    #: Where each patch target lives in the shell, and where its shipped
415    #: copy is. Only targets the live channel can reach are listed.
416    live_files: list[DefenceLiveFile] = Field(default_factory=list)

What a defender may do on this challenge — AWD+ only.

Present iff the challenge declares a patch: block. None everywhere else, which is what lets a client decide whether to render a defence surface at all: without this the only way to find out was to POST a submission and read the refusal, so the web UI had no way to know AWD+ existed.

targets is deliberately public. It is a whitelist, not a secret — a defender is given this source (they can read it in their own box), the refusal message already names a rejected path, and a UI that cannot say which files it will accept sends players guessing.

targets: list[str] = PydanticUndefined
live_shell: bool = False
shell_service: str = ''
shell_is_scored: bool = False
restart_command: str = ''
live_files: list[DefenceLiveFile] = PydanticUndefined
class DefenceLiveFile(ctfy.core.models.CtfyModel):
366class DefenceLiveFile(CtfyModel):
367    """One patch target as the defender's shell sees it."""
368
369    #: Challenge-relative path, as in ``DefenceInfo.targets``.
370    target: str
371    #: Where it lives inside ``DefenceInfo.shell_service`` — the file to edit.
372    path: str
373    #: Where the shipped copy lives in the same service, to restore a
374    #: broken edit by hand; ``""`` when the challenge does not say.
375    pristine_path: str = ""

One patch target as the defender's shell sees it.

target: str = PydanticUndefined
path: str = PydanticUndefined
pristine_path: str = ''
class DefenceMatrixResponse(ctfy.core.models.CtfyModel):
1487class DefenceMatrixResponse(CtfyModel):
1488    """Who has patched what — the defence-side twin of the solve heatmap.
1489
1490    ⚠️ **``challenge_ids`` is configuration, never data.** It is the
1491    competition's defensible challenges, so a challenge nobody has
1492    patched still gets a column — which is information, and which keeps
1493    the columns from re-ordering mid-event as submissions land. The same
1494    reasoning the wall's scene registry states for `hasChallenges`.
1495    """
1496
1497    challenge_ids: list[str] = Field(default_factory=list)
1498    rows: list[DefenceMatrixRow] = Field(default_factory=list)

Who has patched what — the defence-side twin of the solve heatmap.

⚠️ challenge_ids is configuration, never data. It is the competition's defensible challenges, so a challenge nobody has patched still gets a column — which is information, and which keeps the columns from re-ordering mid-event as submissions land. The same reasoning the wall's scene registry states for hasChallenges.

challenge_ids: list[str] = PydanticUndefined
rows: list[DefenceMatrixRow] = PydanticUndefined
class DefenceMatrixRow(ctfy.core.models.CtfyModel):
1467class DefenceMatrixRow(CtfyModel):
1468    """One team's line of the defence matrix."""
1469
1470    team_id: str = ""
1471    team_name: str = ""
1472    #: ⚠️ Present because a wire model that carries a team identity needs
1473    #: a **stated fate**, not a reader. `AwdBoardEntry` and
1474    #: `VirtualGhostRow` shipped without one and both boards rendered the
1475    #: raw tag; `test_anonymity_readers.py` pins the producer set for
1476    #: exactly that reason.
1477    anonymous: bool = False
1478    #: ``{challenge_id: verdict}``.
1479    #:
1480    #: ⚠️ **A pair with no cell is absent, never a present ``""``.** That
1481    #: is `fold_defence_cells`' decision carried onto the wire unchanged:
1482    #: "not judged yet" and "never submitted" are one empty cell to a
1483    #: reader, so a third state would be one nothing draws differently.
1484    cells: dict[str, Verdict] = Field(default_factory=dict)

One team's line of the defence matrix.

team_id: str = ''
team_name: str = ''
anonymous: bool = False
cells: dict[str, ctfy.core.enums.Verdict] = PydanticUndefined
class DefenceSource(ctfy.core.models.CtfyModel):
336class DefenceSource(CtfyModel):
337    """The patchable source of an AWD+ challenge, as the defender gets it.
338
339    AWD+ hands every team the same vulnerable service **and its source**
340    (§15.1) — you cannot patch what you cannot read. Until this there
341    was no way to get it: the only channel was `patch.live`, i.e. ssh
342    into your own box, which is the *classic AWD* shape and leaves
343    virtual participation and upsolve impossible, since after the event
344    there is no box to enter. That is the feature §15.5 calls decisive
345    for a weekly series.
346
347    ⚠️ **Exactly `patch.targets`, never the tree.** A benchmark
348    directory also holds `solution/exploit.py` and the checker, and for
349    some challenges the groundtruth — serving the directory would hand a
350    player the reference exploit and the thing that grades them. The
351    whitelist is already the format's one real security boundary (see
352    `routes/patches.py`), so the download reuses it rather than
353    inventing a second, weaker one.
354
355    ``files`` is keyed by challenge-relative path with **base64**
356    values, which is deliberately the shape `POST /patches` accepts: a
357    defender — or an agent — round-trips download → edit → upload with
358    no transformation, and a binary target does not need a second code
359    path.
360    """
361
362    challenge_id: str
363    files: dict[str, str] = Field(default_factory=dict)

The patchable source of an AWD+ challenge, as the defender gets it.

AWD+ hands every team the same vulnerable service and its source (§15.1) — you cannot patch what you cannot read. Until this there was no way to get it: the only channel was patch.live, i.e. ssh into your own box, which is the classic AWD shape and leaves virtual participation and upsolve impossible, since after the event there is no box to enter. That is the feature §15.5 calls decisive for a weekly series.

⚠️ Exactly patch.targets, never the tree. A benchmark directory also holds solution/exploit.py and the checker, and for some challenges the groundtruth — serving the directory would hand a player the reference exploit and the thing that grades them. The whitelist is already the format's one real security boundary (see routes/patches.py), so the download reuses it rather than inventing a second, weaker one.

files is keyed by challenge-relative path with base64 values, which is deliberately the shape POST /patches accepts: a defender — or an agent — round-trips download → edit → upload with no transformation, and a binary target does not need a second code path.

challenge_id: str = PydanticUndefined
files: dict[str, str] = PydanticUndefined
class DeleteMeRequest(ctfy.core.models.CtfyModel):
383class DeleteMeRequest(CtfyModel):
384    """Body for ``DELETE /me``. ``confirm_display_name`` must match the
385    caller's user display name exactly (case-sensitive) — the typo
386    gate that stops a fat-fingered click from cascading the account's
387    data away. (Falls back to email when display_name is empty.)
388
389    ⚠️ **The typo gate is not the authorization half and never was.** A
390    display name is public — it is on the caller's own profile page and
391    on ``/me`` — so a stolen session reads it in the same request. The
392    second factor is ``current_password`` or ``code``, whichever
393    :func:`~ctfy.server.account_deletion.required_proof` says this
394    account can give; ``MeResponse.deletion_proof`` publishes which, so
395    a client renders one control rather than guessing.
396
397    ⚠️ **Both are optional here rather than one being required**, because
398    which one applies is a property of the account, not of the request —
399    and an account whose answer is ``none`` sends neither, which is
400    every account before this field existed.
401    """
402
403    confirm_display_name: str = Field(min_length=1, max_length=120)
404    #: Re-entered password, for an account holding a password identity.
405    current_password: str = Field(default="", max_length=256)
406    #: The six digits mailed by ``POST /me/deletion-code``, for an
407    #: account with no password and a proved address.
408    code: str = Field(default="", max_length=32)

Body for DELETE /me. confirm_display_name must match the caller's user display name exactly (case-sensitive) — the typo gate that stops a fat-fingered click from cascading the account's data away. (Falls back to email when display_name is empty.)

⚠️ The typo gate is not the authorization half and never was. A display name is public — it is on the caller's own profile page and on /me — so a stolen session reads it in the same request. The second factor is current_password or code, whichever ~ctfy.server.account_deletion.required_proof() says this account can give; MeResponse.deletion_proof publishes which, so a client renders one control rather than guessing.

⚠️ Both are optional here rather than one being required, because which one applies is a property of the account, not of the request — and an account whose answer is none sends neither, which is every account before this field existed.

confirm_display_name: str = PydanticUndefined
current_password: str = ''
code: str = ''
class DeploymentConfig(ctfy.core.models.CtfyModel):
344class DeploymentConfig(CtfyModel):
345    """Read-only deployment posture for the admin overview — env-only
346    knobs that are *not* on public ``/meta`` and not runtime-tunable via
347    platform-settings, so an operator can see how this deployment is
348    configured at a glance. ``rate_limiting_enabled`` is deliberately
349    absent: it is runtime-tunable, so the platform-settings page owns its
350    effective value + source tag. Defaults mirror ``CtfyConfig``."""
351
352    single_process_guard: bool = True
353    auto_migrate: bool = True
354    password_auth_enabled: bool = True
355    # HSTS is emitted only when the deployment is reachable over https
356    # (``oauth_redirect_base`` is https) — see security_headers.
357    hsts_enabled: bool = False

Read-only deployment posture for the admin overview — env-only knobs that are not on public /meta and not runtime-tunable via platform-settings, so an operator can see how this deployment is configured at a glance. rate_limiting_enabled is deliberately absent: it is runtime-tunable, so the platform-settings page owns its effective value + source tag. Defaults mirror CtfyConfig.

single_process_guard: bool = True
auto_migrate: bool = True
password_auth_enabled: bool = True
hsts_enabled: bool = False
class DeviceApproveRequest(ctfy.core.models.CtfyModel):
274class DeviceApproveRequest(CtfyModel):
275    user_code: str = Field(min_length=1, max_length=64)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

user_code: str = PydanticUndefined
class DeviceCodeResponse(ctfy.core.models.CtfyModel):
248class DeviceCodeResponse(CtfyModel):
249    """RFC 8628-style response to ``POST /auth/device/code`` — the CLI
250    shows ``user_code`` + ``verification_uri`` and polls with
251    ``device_code``."""
252
253    device_code: str
254    user_code: str
255    verification_uri: str
256    verification_uri_complete: str
257    expires_in: int
258    interval: int

RFC 8628-style response to POST /auth/device/code — the CLI shows user_code + verification_uri and polls with device_code.

device_code: str = PydanticUndefined
user_code: str = PydanticUndefined
verification_uri: str = PydanticUndefined
verification_uri_complete: str = PydanticUndefined
expires_in: int = PydanticUndefined
interval: int = PydanticUndefined
class DeviceInfoResponse(ctfy.core.models.CtfyModel):
278class DeviceInfoResponse(CtfyModel):
279    """What the approval page shows about a pending device request."""
280
281    status: str
282    requested_at: datetime | None = None
283    requester_ip: str = ""
284    requester_user_agent: str = ""

What the approval page shows about a pending device request.

status: str = PydanticUndefined
requested_at: datetime.datetime | None = None
requester_ip: str = ''
requester_user_agent: str = ''
class DeviceTokenRequest(ctfy.core.models.CtfyModel):
261class DeviceTokenRequest(CtfyModel):
262    device_code: str = Field(min_length=1, max_length=512)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

device_code: str = PydanticUndefined
class DeviceTokenResponse(ctfy.core.models.CtfyModel):
265class DeviceTokenResponse(CtfyModel):
266    """Poll result for ``POST /auth/device/token``. ``status`` is one of
267    ``pending`` / ``approved`` / ``denied`` / ``expired``; ``token`` is
268    set only when ``approved``."""
269
270    status: str
271    token: str = ""

Poll result for POST /auth/device/token. status is one of pending / approved / denied / expired; token is set only when approved.

status: str = PydanticUndefined
token: str = ''
class DifficultyStat(ctfy.core.models.CtfyModel):
465class DifficultyStat(CtfyModel):
466    difficulty: Difficulty | Literal[""] = ""
467    solved: int = 0
468    total: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

difficulty: Union[ctfy.challenge.benchmark.Difficulty, Literal['']] = ''
solved: int = 0
total: int = 0
class EasterEggClaim(ctfy.core.models.CtfyModel):
134class EasterEggClaim(CtfyModel):
135    """Reply for ``POST /easter-eggs/{egg_id}/claim``.
136
137    ``already_unlocked`` lets the page tell "first time, congrats!"
138    from "you've been here before" without an extra GET.
139    """
140
141    egg_id: str
142    achievement: TeamAchievement
143    already_unlocked: bool = False

Reply for POST /easter-eggs/{egg_id}/claim.

already_unlocked lets the page tell "first time, congrats!" from "you've been here before" without an extra GET.

egg_id: str = PydanticUndefined
achievement: TeamAchievement = PydanticUndefined
already_unlocked: bool = False
class EmailCodeAuthInfo(ctfy.core.models.CtfyModel):
54class EmailCodeAuthInfo(CtfyModel):
55    """Whether passwordless email sign-in is offered.
56
57    Tracks outbound-email configuration exactly — there is no separate
58    feature flag, because a sign-in method that cannot deliver its code
59    is not a sign-in method, and offering the button anyway just produces
60    a dead end.
61    """
62
63    enabled: bool = False

Whether passwordless email sign-in is offered.

Tracks outbound-email configuration exactly — there is no separate feature flag, because a sign-in method that cannot deliver its code is not a sign-in method, and offering the button anyway just produces a dead end.

enabled: bool = False
class EmailSuppressionCreate(ctfy.core.models.CtfyModel):
104class EmailSuppressionCreate(CtfyModel):
105    email: str
106    #: Free text; stored as the ``detail`` an operator reads later when
107    #: deciding whether the entry still applies.
108    detail: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

email: str = PydanticUndefined
detail: str = ''
class EmailSuppressionInfo(ctfy.core.models.CtfyModel):
78class EmailSuppressionInfo(CtfyModel):
79    """One address the platform will not send to."""
80
81    email: str
82    #: ``hard_bounce`` | ``complaint`` | ``manual``.
83    reason: str
84    #: The provider's own words, when it gave any.
85    detail: str = ""
86    #: ``webhook`` | ``admin`` — a provider's verdict or a human's.
87    source: str = ""
88    #: The message whose bounce caused this, for cross-referencing the
89    #: outbox row that sent it.
90    provider_message_id: str = ""
91    created_at: str = ""
92    created_by: str = ""

One address the platform will not send to.

email: str = PydanticUndefined
reason: str = PydanticUndefined
detail: str = ''
source: str = ''
provider_message_id: str = ''
created_at: str = ''
created_by: str = ''
class EmailSuppressionListResponse(ctfy.core.models.CtfyModel):
 95class EmailSuppressionListResponse(CtfyModel):
 96    items: list[EmailSuppressionInfo]
 97    total: int
 98    #: False when no Svix secret is set, in which case nothing will ever
 99    #: be added automatically and the list is manual-only. The UI says so
100    #: rather than letting an operator conclude that nothing bounces.
101    webhook_configured: bool

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

items: list[EmailSuppressionInfo] = PydanticUndefined
total: int = PydanticUndefined
webhook_configured: bool = PydanticUndefined
class EmailTemplateInfo(ctfy.core.models.CtfyModel):
55class EmailTemplateInfo(CtfyModel):
56    """One row of the admin template list.
57
58    ``category`` and ``forced`` come from the same registry the
59    preference matrix reads, so the admin sees *why* a message is or is
60    not suppressible without cross-referencing another page.
61    """
62
63    name: str
64    category: str
65    forced: bool
66    subject: str

One row of the admin template list.

category and forced come from the same registry the preference matrix reads, so the admin sees why a message is or is not suppressible without cross-referencing another page.

name: str = PydanticUndefined
category: str = PydanticUndefined
forced: bool = PydanticUndefined
subject: str = PydanticUndefined
class EmailTemplateListResponse(ctfy.core.models.CtfyModel):
69class EmailTemplateListResponse(CtfyModel):
70    templates: list[EmailTemplateInfo]
71    #: False when Resend is unconfigured. The UI disables Send rather
72    #: than letting the operator discover it through a 400.
73    email_configured: bool
74    #: Where a test send goes when the request omits a recipient.
75    default_recipient: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

templates: list[EmailTemplateInfo] = PydanticUndefined
email_configured: bool = PydanticUndefined
default_recipient: str = ''
class EmailVerificationSent(ctfy.core.models.CtfyModel):
140class EmailVerificationSent(CtfyModel):
141    """Answer to ``POST /auth/email-verification`` and ``POST /me/emails``.
142
143    ``handover`` says what redeeming the code that was just sent would
144    do to *another* account, so the person can be told before they go to
145    their inbox rather than after.
146
147    ⚠️ **The default is ``"none"`` and that is safe because the gate is
148    elsewhere.** A producer that forgets, or an older server a newer
149    client is talking to, reports an ordinary claim — and the only
150    destructive outcome, orphaning the other account, is refused at the
151    *exchange* unless that request carries an explicit acknowledgement.
152    So silence here costs a missing warning, never a silent taking; the
153    reverse default would be a new claim about somebody else's account.
154    """
155
156    sent: bool = True
157    expires_in_minutes: int = 0
158    handover: HandoverKind = "none"

Answer to POST /auth/email-verification and POST /me/emails.

handover says what redeeming the code that was just sent would do to another account, so the person can be told before they go to their inbox rather than after.

⚠️ The default is "none" and that is safe because the gate is elsewhere. A producer that forgets, or an older server a newer client is talking to, reports an ordinary claim — and the only destructive outcome, orphaning the other account, is refused at the exchange unless that request carries an explicit acknowledgement. So silence here costs a missing warning, never a silent taking; the reverse default would be a new claim about somebody else's account.

sent: bool = True
expires_in_minutes: int = 0
handover: Literal['none', 'move', 'orphans'] = 'none'
class EmailVerificationStatus(ctfy.core.models.CtfyModel):
161class EmailVerificationStatus(CtfyModel):
162    """Where an account stands on proving its address.
163
164    ``verified`` is a ``computed_field`` rather than a stored bool
165    because this is a wire model and the two must never be able to
166    disagree — one fact, published in the shape a client wants to read.
167    """
168
169    email: str = ""
170    verified_at: AwareDatetime | None = None
171
172    @computed_field
173    @property
174    def verified(self) -> bool:
175        return self.verified_at is not None

Where an account stands on proving its address.

verified is a computed_field rather than a stored bool because this is a wire model and the two must never be able to disagree — one fact, published in the shape a client wants to read.

email: str = ''
verified_at: pydantic.types.AwareDatetime | None = None
verified: bool
172    @computed_field
173    @property
174    def verified(self) -> bool:
175        return self.verified_at is not None
class ErrorResponse(ctfy.core.models.CtfyModel):
386class ErrorResponse(CtfyModel):
387    """Unified shape for every HTTP 4xx/5xx body produced by platform routes.
388
389    ``detail`` mirrors the pre-existing field (so SDK / CLI / frontend code
390    that reads ``data.detail`` keeps working); ``code`` adds a stable
391    machine-readable identifier derived from the raising exception class,
392    and ``timestamp`` is server-side UTC in ISO 8601 — useful when
393    correlating client-side and server-side logs.
394
395    ``detail`` is a union because three sources feed the envelope:
396      * a plain string from legacy ``HTTPException`` / ``raise_error`` calls;
397      * a structured dict (``{"message": "...", "competition_id": ...}``
398        or the legacy ``{"error": "..."}``) when the route wants to attach
399        machine-readable context the frontend can branch on;
400      * a list of FastAPI ``RequestValidationError`` issues, wrapped under
401        ``{"message", "issues"}`` by ``_handle_validation_error``.
402    """
403
404    code: str
405    detail: str | dict[str, Any] | list[dict[str, Any]]
406    timestamp: datetime | None = None

Unified shape for every HTTP 4xx/5xx body produced by platform routes.

detail mirrors the pre-existing field (so SDK / CLI / frontend code that reads data.detail keeps working); code adds a stable machine-readable identifier derived from the raising exception class, and timestamp is server-side UTC in ISO 8601 — useful when correlating client-side and server-side logs.

detail is a union because three sources feed the envelope:

  • a plain string from legacy HTTPException / raise_error calls;
  • a structured dict ({"message": "...", "competition_id": ...} or the legacy {"error": "..."}) when the route wants to attach machine-readable context the frontend can branch on;
  • a list of FastAPI RequestValidationError issues, wrapped under {"message", "issues"} by _handle_validation_error.
code: str = PydanticUndefined
detail: str | dict[str, typing.Any] | list[dict[str, typing.Any]] = PydanticUndefined
timestamp: datetime.datetime | None = None
class EvalAllowanceGrant(ctfy.core.models.CtfyModel):
384class EvalAllowanceGrant(CtfyModel):
385    """A grant as an operator types it."""
386
387    user_id: str = Field(min_length=1)
388    competition_id: str = Field(min_length=1)
389    #: ⚠️ ``0`` is allowed and funds nothing — an operator can create the
390    #: row before deciding the amount. Negative is not: it would read as
391    #: a debt and ``has_room`` would refuse it anyway, so the form says
392    #: so instead of storing a number nothing can mean.
393    cap_usd: float = Field(ge=0.0)
394    note: str = Field(default="", max_length=200)

A grant as an operator types it.

user_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
cap_usd: float = PydanticUndefined
note: str = ''
class EvalAllowanceRow(ctfy.core.models.CtfyModel):
348class EvalAllowanceRow(CtfyModel):
349    """One grant, as an operator or its holder reads it.
350
351    ⚠️ ``remaining_usd`` and ``exhausted`` are **computed here**, on a
352    wire model, from the same arithmetic the dispatch uses. A client
353    subtracting for itself would eventually disagree with the server
354    about whether a run may start — and it is the server's refusal that
355    decides.
356    """
357
358    user_id: str = ""
359    #: The account's own name, so an operator's list is readable without
360    #: a second lookup per row.
361    display_name: str = ""
362    competition_id: str = ""
363    competition_title: str = ""
364    cap_usd: float = 0.0
365    spent_usd: float = 0.0
366    granted_by_user_id: str = ""
367    granted_at: str = ""
368    note: str = ""
369
370    @computed_field
371    @property
372    def remaining_usd(self) -> float:
373        """⚠️ Clamped at zero. An overshoot is real and stays in
374        ``spent_usd``; a negative remainder invites a reader to add it to
375        the next grant."""
376        return Allowance(cap_usd=self.cap_usd, spent_usd=self.spent_usd).remaining_usd
377
378    @computed_field
379    @property
380    def exhausted(self) -> bool:
381        return not Allowance(cap_usd=self.cap_usd, spent_usd=self.spent_usd).has_room

One grant, as an operator or its holder reads it.

⚠️ remaining_usd and exhausted are computed here, on a wire model, from the same arithmetic the dispatch uses. A client subtracting for itself would eventually disagree with the server about whether a run may start — and it is the server's refusal that decides.

user_id: str = ''
display_name: str = ''
competition_id: str = ''
competition_title: str = ''
cap_usd: float = 0.0
spent_usd: float = 0.0
granted_by_user_id: str = ''
granted_at: str = ''
note: str = ''
remaining_usd: float
370    @computed_field
371    @property
372    def remaining_usd(self) -> float:
373        """⚠️ Clamped at zero. An overshoot is real and stays in
374        ``spent_usd``; a negative remainder invites a reader to add it to
375        the next grant."""
376        return Allowance(cap_usd=self.cap_usd, spent_usd=self.spent_usd).remaining_usd

⚠️ Clamped at zero. An overshoot is real and stays in spent_usd; a negative remainder invites a reader to add it to the next grant.

exhausted: bool
378    @computed_field
379    @property
380    def exhausted(self) -> bool:
381        return not Allowance(cap_usd=self.cap_usd, spent_usd=self.spent_usd).has_room
class EvalArtifactBucket(ctfy.core.models.CtfyModel):
1489class EvalArtifactBucket(CtfyModel):
1490    """One column of the archive-size histogram."""
1491
1492    label: str = ""
1493    runs: int = 0

One column of the archive-size histogram.

label: str = ''
runs: int = 0
class EvalArtifactReport(ctfy.core.models.CtfyModel):
1496class EvalArtifactReport(CtfyModel):
1497    """How much disk the collected agent archives are using.
1498
1499    ``docs/agent-artifact-plan.md`` §7 sized the archive from one half of
1500    it: the session half was measured, the working-directory half never
1501    was, and it is unbounded by construction. Both the per-archive cap
1502    and the retention window were chosen against that gap — and the
1503    retention default is *forever*, so nothing reclaims an archive until
1504    somebody sets one. This is the number that turns those into decisions
1505    somebody can make.
1506
1507    ⚠️ **``by_status`` is the reason the headline is what it is.** Zero
1508    stored bytes is four different facts — nobody has run an agent, this
1509    deployment has no bucket (``not_configured``), collection is failing
1510    (``capture_failed`` / ``upload_failed``), or a window reclaimed them
1511    (``reclaimed``) — and they read identically as a single ``0``. The
1512    same shape the registry usage report needed its scan receipt for.
1513    """
1514
1515    stored_runs: int = 0
1516    stored_bytes: int = 0
1517    largest_bytes: int = 0
1518    buckets: list[EvalArtifactBucket] = Field(default_factory=list)
1519    #: ``{artifact_status: runs}`` over **every** run, archive or not.
1520    by_status: dict[str, int] = Field(default_factory=dict)
1521    #: The window in force, echoed so the page does not restate the
1522    #: setting resolver's precedence (DB > env > default) in a browser.
1523    #: ``0`` is forever, and forever is the shipped default.
1524    retention_s: int = 0
1525    #: Whether this deployment can keep an archive at all. ⚠️ Server-side
1526    #: and defaulting **False** — a client reading it must never be the
1527    #: one deciding, and an older server must not claim a bucket it
1528    #: never reported.
1529    storage_configured: bool = False

How much disk the collected agent archives are using.

docs/agent-artifact-plan.md §7 sized the archive from one half of it: the session half was measured, the working-directory half never was, and it is unbounded by construction. Both the per-archive cap and the retention window were chosen against that gap — and the retention default is forever, so nothing reclaims an archive until somebody sets one. This is the number that turns those into decisions somebody can make.

⚠️ by_status is the reason the headline is what it is. Zero stored bytes is four different facts — nobody has run an agent, this deployment has no bucket (not_configured), collection is failing (capture_failed / upload_failed), or a window reclaimed them (reclaimed) — and they read identically as a single 0. The same shape the registry usage report needed its scan receipt for.

stored_runs: int = 0
stored_bytes: int = 0
largest_bytes: int = 0
buckets: list[EvalArtifactBucket] = PydanticUndefined
by_status: dict[str, int] = PydanticUndefined
retention_s: int = 0
storage_configured: bool = False
class EvalEntryRequest(ctfy.core.models.CtfyModel):
424class EvalEntryRequest(CtfyModel):
425    """An entrant putting their own agent into one competition.
426
427    ⚠️ **Three ids, a sample count and two limits it may only tighten.**
428    The operator's submit request also carries ``name``, ``tier`` and
429    ``max_cost_usd``; none of them belongs to a caller who is not the
430    operator. ``tier`` is the one that matters — a field a client can set
431    is a field a client can forge, and a forged ``official`` would put a
432    self-run campaign on the default public board. The route stamps it,
433    so the absence here is a second layer rather than the only one.
434
435    ⚠️ The two that *are* here are safe for the opposite reason:
436    ``_harness_timeout`` clamps the wall clock to the deployment's own,
437    so naming one can only shorten a run. An entrant knows something the
438    operator does not — that their agent gives up after ten minutes, or
439    that it needs every second it can get on this set — and the value
440    that number decides is their own result.
441    """
442
443    harness_id: str = Field(min_length=1)
444    model_id: str = Field(min_length=1)
445    competition_id: str = Field(min_length=1)
446    #: ⚠️ Clamped by the route, not trusted from here. pass@k multiplies
447    #: the fan-out, so this is the field that turns one submission into
448    #: an unbounded number of container runs on somebody else's nodes.
449    num_samples: int = Field(default=1, ge=1)
450    #: ⚠️ ``0`` inherits the deployment's setting on both, never
451    #: "unbounded" — the reading ``max_cost_usd`` takes on the operator's
452    #: form and the opposite of what is safe here. The wall clock is
453    #: additionally clamped at dispatch, so this can shorten a run and
454    #: never lengthen one.
455    timeout_s: int = Field(default=0, ge=0)
456    max_turns: int = Field(default=0, ge=0)

An entrant putting their own agent into one competition.

⚠️ Three ids, a sample count and two limits it may only tighten. The operator's submit request also carries name, tier and max_cost_usd; none of them belongs to a caller who is not the operator. tier is the one that matters — a field a client can set is a field a client can forge, and a forged official would put a self-run campaign on the default public board. The route stamps it, so the absence here is a second layer rather than the only one.

⚠️ The two that are here are safe for the opposite reason: _harness_timeout clamps the wall clock to the deployment's own, so naming one can only shorten a run. An entrant knows something the operator does not — that their agent gives up after ten minutes, or that it needs every second it can get on this set — and the value that number decides is their own result.

harness_id: str = PydanticUndefined
model_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
num_samples: int = 1
timeout_s: int = 0
max_turns: int = 0
class EvalLeaderboard(ctfy.core.models.CtfyModel):
675class EvalLeaderboard(CtfyModel):
676    """The full leaderboard: ranked model standings + the dimensions available."""
677
678    competition_id: str = ""
679    challenge_count: int = 0
680    #: the single corpus version all runs share (reproducibility header);
681    #: blank when runs span mixed corpus revisions.
682    corpus_sha: str = ""
683    dimensions: list[str] = Field(default_factory=list)
684    models: list[ModelStanding] = Field(default_factory=list)
685    #: vendor scorecards (pooled across each vendor's models); unattributed
686    #: models (blank vendor) are omitted.
687    vendors: list[VendorStanding] = Field(default_factory=list)
688    #: harness scorecards (pooled across each harness's runs) — the harness
689    #: comparison axis. The blank harness id is the built-in ctfy harness.
690    harnesses: list[HarnessStanding] = Field(default_factory=list)
691    #: per-(model, harness) cross-tab cells — the apples-to-apples view that
692    #: isolates the harness effect on a fixed model. Empty unless ≥2 harnesses.
693    cross: list[HarnessModelCell] = Field(default_factory=list)
694    #: run activity + run-level solve rate bucketed by UTC day (chronological).
695    trend: list[EvalTrendPoint] = Field(default_factory=list)

The full leaderboard: ranked model standings + the dimensions available.

competition_id: str = ''
challenge_count: int = 0
corpus_sha: str = ''
dimensions: list[str] = PydanticUndefined
models: list[ModelStanding] = PydanticUndefined
vendors: list[VendorStanding] = PydanticUndefined
harnesses: list[HarnessStanding] = PydanticUndefined
cross: list[HarnessModelCell] = PydanticUndefined
trend: list[EvalTrendPoint] = PydanticUndefined
class EvalRunCoverage(ctfy.core.models.CtfyModel):
1566class EvalRunCoverage(CtfyModel):
1567    """How much of the range this run actually reached.
1568
1569    ``#1582``'s first question: *which planted vulnerabilities did the
1570    agent find, and which did it walk past*. The Reporting grade cannot
1571    answer it — it is graded against the team's own captures, so it
1572    measures whether what was written is true and says nothing about how
1573    much there was to write about.
1574
1575    ⚠️ **Two numbers, never one.** Coverage and fidelity move
1576    independently, and blending them is what made a run that found three
1577    of thirty-seven publish a 100. This model is the coverage half and
1578    carries no score at all.
1579
1580    ⚠️ **Per run, and that is the whole reason it is addressed by a run
1581    id.** An eval entrant's team is one synthetic team per
1582    ``(owner, harness, model)``, so a team-scoped reading pools every
1583    sample the agent has ever run — which is the second defect in the
1584    same issue.
1585    """
1586
1587    run_id: str = ""
1588    challenge_id: str = ""
1589    #: Every question the challenge declares, in the spec's own order.
1590    #: The **spec** decides the rows: a list derived from what was found
1591    #: has a success in every entry and cannot report a gap.
1592    rows: list[QuestionCoverageRow] = Field(default_factory=list)
1593    captured: int = 0
1594    missed: int = 0
1595    #: Questions no agent could have been credited for in this
1596    #: competition's format. ⚠️ A finding about the **range**, never about
1597    #: the model — counting these as missed reports the format's shape as
1598    #: the agent's failure.
1599    uncreditable: int = 0
1600    #: Whether the report was the only surface open, which is what makes
1601    #: ``uncreditable`` non-zero. Published so a reader is not left
1602    #: inferring the competition's mode from a count.
1603    report_only: bool = False
1604    #: Whether a Reporting grade was found for this run's team on this
1605    #: range. ⚠️ **Defaults ``False``**, and the pair with the count below
1606    #: is the point: ``found_not_described: 0`` means *nothing was walked
1607    #: past silently* only when this is ``True``. With no grade the same
1608    #: zero means *nobody has judged the document yet*, and the two send
1609    #: a reader to opposite places — ``ReportClaimsPage.read``'s rule, one
1610    #: feature over.
1611    graded: bool = False
1612    #: Questions this run captured that the graded report never mentions.
1613    #:
1614    #: ⚠️ **Not the grade's own ``not_mentioned``.** That one counts over
1615    #: ``build_fact_set``'s set, which is the team's *lifetime* captures
1616    #: on the range — and an eval entrant is one synthetic team per
1617    #: ``(owner, harness, model)``, so it pools every sample the agent
1618    #: ever ran. This is its restriction to what *this* run found, which
1619    #: is the only version that answers for a run.
1620    found_not_described: int = 0
1621    #: ⚠️ **Defaults ``False``, and the direction is deliberate.** An
1622    #: empty ``rows`` means either *the catalog no longer has this
1623    #: challenge* or *it declares nothing*, and those get opposite
1624    #: readings — the first makes the whole coverage unanswerable. The
1625    #: safe default is the one that refuses to publish ``0 of 0`` as a
1626    #: clean sheet.
1627    challenge_in_catalog: bool = False

How much of the range this run actually reached.

#1582's first question: which planted vulnerabilities did the agent find, and which did it walk past. The Reporting grade cannot answer it — it is graded against the team's own captures, so it measures whether what was written is true and says nothing about how much there was to write about.

⚠️ Two numbers, never one. Coverage and fidelity move independently, and blending them is what made a run that found three of thirty-seven publish a 100. This model is the coverage half and carries no score at all.

⚠️ Per run, and that is the whole reason it is addressed by a run id. An eval entrant's team is one synthetic team per (owner, harness, model), so a team-scoped reading pools every sample the agent has ever run — which is the second defect in the same issue.

run_id: str = ''
challenge_id: str = ''
rows: list[QuestionCoverageRow] = PydanticUndefined
captured: int = 0
missed: int = 0
uncreditable: int = 0
report_only: bool = False
graded: bool = False
found_not_described: int = 0
challenge_in_catalog: bool = False
class EvalTrendPoint(ctfy.core.models.CtfyModel):
189class EvalTrendPoint(CtfyModel):
190    """Eval activity + run-level performance for one UTC day.
191
192    ``solve_rate`` here is *run-level* (fully-solved runs ÷ runs that day),
193    a different lens from the per-challenge best-of-k rate in the standings:
194    it tracks how the fleet is performing over time, not cumulative coverage.
195    """
196
197    period: str = ""  # UTC calendar day, YYYY-MM-DD
198    runs: int = 0
199    fully_solved: int = 0
200    solve_rate: float = 0.0
201    cost_usd: float = 0.0

Eval activity + run-level performance for one UTC day.

solve_rate here is run-level (fully-solved runs ÷ runs that day), a different lens from the per-challenge best-of-k rate in the standings: it tracks how the fleet is performing over time, not cumulative coverage.

period: str = ''
runs: int = 0
fully_solved: int = 0
solve_rate: float = 0.0
cost_usd: float = 0.0
class FeedbackStats(ctfy.core.models.CtfyModel):
28class FeedbackStats(CtfyModel):
29    """Public aggregate counts for one challenge.
30
31    ``counts`` is dense over every Reaction key (zeros included) so the
32    frontend can render the 9 chips uniformly without filling in
33    missing keys. ``total`` is the sum across all reactions — under
34    multi-select this is "total taps" not "distinct users", so it can
35    exceed the unique-reactor count when players stack chips.
36    """
37
38    challenge_id: str
39    counts: dict[Reaction, int]
40    total: int

Public aggregate counts for one challenge.

counts is dense over every Reaction key (zeros included) so the frontend can render the 9 chips uniformly without filling in missing keys. total is the sum across all reactions — under multi-select this is "total taps" not "distinct users", so it can exceed the unique-reactor count when players stack chips.

challenge_id: str = PydanticUndefined
counts: dict[typing.Literal['addictive', 'mindblown', 'learned', 'goat_setter', 'overthought', 'guessy', 'brutal', 'buggy', 'infra_broken'], int] = PydanticUndefined
total: int = PydanticUndefined
class GauntletBuildCost(ctfy.core.models.CtfyModel):
211class GauntletBuildCost(CtfyModel):
212    """What building this range would cost, and whether a node can take it.
213
214    ⚠️ **No duration.** A number of seconds here would be a claim about a
215    machine this code has never seen — the wall-clock-constant mistake
216    this repository has had to fix repeatedly. What travels is countable:
217    containers, declared memory, and how many milestones a reference
218    solution must capture.
219
220    ⚠️ ``refusal`` is empty when the build may go ahead. A node that
221    reports no capacity **admits**, because "did not say" and "has none"
222    are opposite facts and reading silence as a refusal would make adding
223    this check an outage for every deployment whose node predates it.
224    """
225
226    services: int = 0
227    memory_mb: int = 0
228    milestones: int = 0
229    #: Why it may not be built here. Empty means it may.
230    refusal: str = ""
231    #: The run, in order — shown before the button rather than after, so
232    #: an operator sees what it will do and what it will cost.
233    steps: list[GauntletBuildStep] = Field(default_factory=list)

What building this range would cost, and whether a node can take it.

⚠️ No duration. A number of seconds here would be a claim about a machine this code has never seen — the wall-clock-constant mistake this repository has had to fix repeatedly. What travels is countable: containers, declared memory, and how many milestones a reference solution must capture.

⚠️ refusal is empty when the build may go ahead. A node that reports no capacity admits, because "did not say" and "has none" are opposite facts and reading silence as a refusal would make adding this check an outage for every deployment whose node predates it.

services: int = 0
memory_mb: int = 0
milestones: int = 0
refusal: str = ''
steps: list[GauntletBuildStep] = PydanticUndefined
class GauntletBuildStep(ctfy.core.models.CtfyModel):
195class GauntletBuildStep(CtfyModel):
196    """One step of a verification run, before it has run.
197
198    ⚠️ **A key and a weight, never a label and a duration.** The label is
199    copy and lives in a locale bundle keyed on ``key``; the weight is
200    this step's share of the run. A number of seconds here would be the
201    wall-clock-constant mistake `GauntletBuildCost` refuses below — and
202    the *shape* is what actually travels: `compose up --wait` dominates,
203    and dominates harder as the estate grows.
204    """
205
206    key: VerificationStepKey
207    #: Share of the run, relative to the other steps. Never zero.
208    weight: int = 1

One step of a verification run, before it has run.

⚠️ A key and a weight, never a label and a duration. The label is copy and lives in a locale bundle keyed on key; the weight is this step's share of the run. A number of seconds here would be the wall-clock-constant mistake GauntletBuildCost refuses below — and the shape is what actually travels: compose up --wait dominates, and dominates harder as the estate grows.

key: ctfy.core.enums.VerificationStepKey = PydanticUndefined
weight: int = 1
class GauntletLibraryResponse(ctfy.core.models.CtfyModel):
70class GauntletLibraryResponse(CtfyModel):
71    """What this deployment's corpus can build a range out of.
72
73    ⚠️ ``available`` is the honest half. A corpus that is a release
74    tarball, or a submodule nobody initialised, has no compiler — and
75    the page has to say *that*, rather than showing an empty library
76    which reads as "somebody deleted the modules".
77    """
78
79    available: bool = False
80    #: Why not, when ``available`` is False. Empty otherwise.
81    reason: str = ""
82    modules: list[GauntletModuleInfo] = Field(default_factory=list)
83    #: Capabilities something in the library produces. ⚠️ Only these are
84    #: offerable as a goal: a capability nothing grants is the planner's
85    #: own `Unsatisfiable`, so listing it would be a dropdown whose
86    #: entries are known in advance to fail.
87    goals: list[str] = Field(default_factory=list)
88    #: Modules whose tier is ``defense`` — the only ones a probability
89    #: may be attached to.
90    defenses: list[str] = Field(default_factory=list)
91    #: Named starting points, in the order a form should offer them.
92    #: ⚠️ Server-side because they are values that travel with the
93    #: corpus, not a closed type the client must be complete over — the
94    #: opposite of the `lib/flavors.ts` family, and the reason they are
95    #: not a frontend constant.
96    presets: list[GauntletPreset] = Field(default_factory=list)

What this deployment's corpus can build a range out of.

⚠️ available is the honest half. A corpus that is a release tarball, or a submodule nobody initialised, has no compiler — and the page has to say that, rather than showing an empty library which reads as "somebody deleted the modules".

available: bool = False
reason: str = ''
modules: list[GauntletModuleInfo] = PydanticUndefined
goals: list[str] = PydanticUndefined
defenses: list[str] = PydanticUndefined
presets: list[GauntletPreset] = PydanticUndefined
class GauntletModuleInfo(ctfy.core.models.CtfyModel):
25class GauntletModuleInfo(CtfyModel):
26    """One attack-edge module, as an organiser picks from the library."""
27
28    id: str
29    title: str = ""
30    #: ``live`` | ``dead-end`` | ``hardened`` | ``defense``. Left a plain
31    #: string on purpose: the tier vocabulary belongs to the compiler in
32    #: the corpus, which ships on its own cadence, and a `Literal` here
33    #: would make a corpus that adds a fifth tier unreadable by an older
34    #: platform rather than merely unfamiliar.
35    tier: str = ""
36    memory_mb: int = 0
37    produces: list[str] = Field(default_factory=list)
38    consumes: list[str] = Field(default_factory=list)
39    milestones: int = 0

One attack-edge module, as an organiser picks from the library.

id: str = PydanticUndefined
title: str = ''
tier: str = ''
memory_mb: int = 0
produces: list[str] = PydanticUndefined
consumes: list[str] = PydanticUndefined
milestones: int = 0
class GauntletPlanRequest(ctfy.core.models.CtfyModel):
 99class GauntletPlanRequest(CtfyModel):
100    """The knobs that decide the estate — nothing that only names it.
101
102    ⚠️ **Split from the preview request rather than copied.** The plan
103    route answers in 4.4 ms and writes nothing; the preview route
104    renders 76 files to read a topology back. They take the same
105    decisions and differ only in what they cost, so the preview request
106    *is* this one plus the fields that appear in the rendered scenario —
107    a second flat model would be two rosters of knobs somebody has to
108    keep equal.
109    """
110
111    goal: str = "loot:crown"
112    seed: str = "preview"
113    dead_end_ratio: float = Field(default=0.25, ge=0.0, lt=1.0)
114    hardened_ratio: float = Field(default=0.35, ge=0.0, lt=1.0)
115    #: module id -> probability it is deployed. ⚠️ A control is present
116    #: or absent, so this is sampled per render rather than taken as a
117    #: share; the studio pins it to 1.0 when an organiser wants to see
118    #: the range *with* the control.
119    defenses: dict[str, float] = Field(default_factory=dict)
120    #: Whole-estate declared memory. ``0`` is uncapped, as everywhere
121    #: else in this codebase.
122    budget_mb: int = Field(default=0, ge=0)
123    entries: int = Field(default=0, ge=0)
124    #: Upper bound of the entry-count range; ``0`` means "same as
125    #: ``entries``". ⚠️ The *seed* picks inside it, which is what makes
126    #: two sittings of one configuration differ in outline rather than
127    #: only in contents — measured over 300 seeds, a fixed 4 gave 5
128    #: distinct shapes and 15 entry-point sets, a [3, 6] range 20 and 42.
129    #: `0` rather than a nullable field, because an absent bound must
130    #: mean what a point meant and never make a new claim.
131    entries_max: int = Field(default=0, ge=0)
132    depth_levels: int = Field(default=0, ge=0)
133    milestones: int = Field(default=0, ge=0)
134    #: How many network segments the estate should span. ⚠️ The axis the
135    #: generator's `depth_levels` could not be: enrichment admits only a
136    #: module whose requirements the plan already satisfies, which
137    #: places it at or above an existing depth — but a *gate* satisfies
138    #: that same rule and does open a segment. `0` means "do not ask",
139    #: which is what every scenario written before the axis meant.
140    segments: int = Field(default=0, ge=0)
141    #: Upper bound of the segment-count range; ``0`` means "same as
142    #: ``segments``". ⚠️ A *point* target makes the topology less varied
143    #: than a range — measured over 300 seeds, 5 gave 8 distinct gate
144    #: graphs against [4, 6]'s 14 — because every sitting then has the
145    #: same number of rooms. Same reason `entries_max` exists.
146    segments_max: int = Field(default=0, ge=0)

The knobs that decide the estate — nothing that only names it.

⚠️ Split from the preview request rather than copied. The plan route answers in 4.4 ms and writes nothing; the preview route renders 76 files to read a topology back. They take the same decisions and differ only in what they cost, so the preview request is this one plus the fields that appear in the rendered scenario — a second flat model would be two rosters of knobs somebody has to keep equal.

goal: str = 'loot:crown'
seed: str = 'preview'
dead_end_ratio: float = 0.25
hardened_ratio: float = 0.35
defenses: dict[str, float] = PydanticUndefined
budget_mb: int = 0
entries: int = 0
entries_max: int = 0
depth_levels: int = 0
milestones: int = 0
segments: int = 0
segments_max: int = 0
class GauntletPlanResponse(ctfy.core.models.CtfyModel):
172class GauntletPlanResponse(CtfyModel):
173    """The shape these knobs produce — no estate written anywhere.
174
175    ⚠️ ``asked`` and ``got`` are separate because they answer different
176    questions. The seed draws a target inside the requested ranges
177    (``asked``); the library then either reaches it or does not
178    (``got``), and ``shortfalls`` says by how much and *why* — a pool
179    that ran dry and a budget that refused every candidate are opposite
180    causes with opposite fixes.
181    """
182
183    seed: str
184    module_ids: list[str] = Field(default_factory=list)
185    #: tier -> how many of the finished estate it is.
186    tiers: dict[str, int] = Field(default_factory=dict)
187    declared_memory_mb: int = 0
188    shortfalls: list[GauntletShortfall] = Field(default_factory=list)
189    #: axis -> the number this seed drew inside the requested range.
190    asked: dict[str, int] = Field(default_factory=dict)
191    #: axis -> the number the finished estate actually measures.
192    got: dict[str, int] = Field(default_factory=dict)

The shape these knobs produce — no estate written anywhere.

⚠️ asked and got are separate because they answer different questions. The seed draws a target inside the requested ranges (asked); the library then either reaches it or does not (got), and shortfalls says by how much and why — a pool that ran dry and a budget that refused every candidate are opposite causes with opposite fixes.

seed: str = PydanticUndefined
module_ids: list[str] = PydanticUndefined
tiers: dict[str, int] = PydanticUndefined
declared_memory_mb: int = 0
shortfalls: list[GauntletShortfall] = PydanticUndefined
asked: dict[str, int] = PydanticUndefined
got: dict[str, int] = PydanticUndefined
class GauntletPreset(ctfy.core.models.CtfyModel):
42class GauntletPreset(CtfyModel):
43    """A named bundle of shape knobs — a starting point, not a promise.
44
45    ⚠️ **Sent exactly as authored: not clamped to what this corpus can
46    reach, and the reason is a measurement.** A preset asking for more
47    than the library holds does *not* fail — `plan_chain` only refuses
48    an unreachable goal, and the shape axes are approached by `enrich`
49    on a best-effort basis. Asked for `entries 9 / depth 20 /
50    milestones 60`, two seeds both answered 200 with `got` resting at
51    6 / 9 / 33. Quietly under-delivering is worse than refusing, so the
52    honest surface is the per-plan `asked` vs `got` pair, which is exact
53    for the seed in front of the organiser; clamping here would make the
54    default a new claim about somebody else's library.
55    """
56
57    id: str
58    #: Machine-readable; the browser owns the label so it can translate.
59    entries: int = 0
60    entries_max: int = 0
61    depth_levels: int = 0
62    milestones: int = 0
63    segments: int = 0
64    segments_max: int = 0
65    dead_end_ratio: float = 0.25
66    hardened_ratio: float = 0.35
67    budget_mb: int = 0

A named bundle of shape knobs — a starting point, not a promise.

⚠️ Sent exactly as authored: not clamped to what this corpus can reach, and the reason is a measurement. A preset asking for more than the library holds does not fail — plan_chain only refuses an unreachable goal, and the shape axes are approached by enrich on a best-effort basis. Asked for entries 9 / depth 20 / milestones 60, two seeds both answered 200 with got resting at 6 / 9 / 33. Quietly under-delivering is worse than refusing, so the honest surface is the per-plan asked vs got pair, which is exact for the seed in front of the organiser; clamping here would make the default a new claim about somebody else's library.

id: str = PydanticUndefined
entries: int = 0
entries_max: int = 0
depth_levels: int = 0
milestones: int = 0
segments: int = 0
segments_max: int = 0
dead_end_ratio: float = 0.25
hardened_ratio: float = 0.35
budget_mb: int = 0
class GauntletPreviewRequest(ctfy.server.models.GauntletPlanRequest):
149class GauntletPreviewRequest(GauntletPlanRequest):
150    """The same knobs, plus what only a rendered scenario needs."""
151
152    scenario_id: str = "GAUNTLET-PREVIEW-001"
153    name: str = "Range preview"
154    company: str = "Vantage Logistics"
155    description: str = ""
156    difficulty: str = "medium"
157    tags: list[str] = Field(default_factory=list)

The same knobs, plus what only a rendered scenario needs.

scenario_id: str = 'GAUNTLET-PREVIEW-001'
name: str = 'Range preview'
company: str = 'Vantage Logistics'
description: str = ''
difficulty: str = 'medium'
tags: list[str] = PydanticUndefined
class GauntletPreviewResponse(ctfy.core.models.CtfyModel):
236class GauntletPreviewResponse(CtfyModel):
237    """The range these knobs would produce, without producing it.
238
239    ⚠️ **The shape is nested, not restated.** A flat copy of the plan
240    fields beside the blueprint is the shape `admin_awd._info` shipped —
241    a hand-written projection that named nine of twelve fields, so the
242    organiser's own page reported every armed match as unarmed. One
243    object, built once, read by both routes.
244    """
245
246    scenario_id: str
247    plan: GauntletPlanResponse
248    #: The estate exactly as it would be rendered — same model, same
249    #: renderer, as a range that already exists.
250    blueprint: ChallengeBlueprint
251    #: The committed half, ready to paste into the corpus's `scenarios/`.
252    scenario_yaml: str = ""
253    #: What a build of this estate would cost on this deployment.
254    build: GauntletBuildCost = Field(default_factory=GauntletBuildCost)

The range these knobs would produce, without producing it.

⚠️ The shape is nested, not restated. A flat copy of the plan fields beside the blueprint is the shape admin_awd._info shipped — a hand-written projection that named nine of twelve fields, so the organiser's own page reported every armed match as unarmed. One object, built once, read by both routes.

scenario_id: str = PydanticUndefined
plan: GauntletPlanResponse = PydanticUndefined
blueprint: ChallengeBlueprint = PydanticUndefined
scenario_yaml: str = ''
build: GauntletBuildCost = PydanticUndefined
class GauntletRangeCreate(ctfy.server.models.GauntletPreviewRequest):
257class GauntletRangeCreate(GauntletPreviewRequest):
258    """Ask this deployment to keep a range built from these knobs.
259
260    ⚠️ **A slug, never an id.** The id is minted by
261    `ctfy.core.generated_ranges.generated_range_id` so it always carries
262    the generated bucket's own prefix — which is what keeps
263    `find_benchmark_dir`'s fast path landing in our bucket, and what
264    stops a later corpus commit from shadowing a range this deployment
265    made. Letting a caller name the id outright would put that guarantee
266    in a text field.
267
268    ⚠️ It extends the *preview* request rather than restating it, so the
269    knobs an organiser tuned against are the knobs that get stored. The
270    inherited ``scenario_id`` has no meaning here and is ignored.
271    """
272
273    slug: str = ""

Ask this deployment to keep a range built from these knobs.

⚠️ A slug, never an id. The id is minted by ctfy.core.generated_ranges.generated_range_id so it always carries the generated bucket's own prefix — which is what keeps find_benchmark_dir's fast path landing in our bucket, and what stops a later corpus commit from shadowing a range this deployment made. Letting a caller name the id outright would put that guarantee in a text field.

⚠️ It extends the preview request rather than restating it, so the knobs an organiser tuned against are the knobs that get stored. The inherited scenario_id has no meaning here and is ignored.

slug: str = ''
class GauntletRangeCreated(ctfy.core.models.CtfyModel):
303class GauntletRangeCreated(CtfyModel):
304    """The stored source, and the task that will build it.
305
306    ⚠️ Both, because the two are the halves an organiser has to follow:
307    the range is a thing that now exists in this deployment's records,
308    and the build is something running that can fail. Answering with the
309    task alone is the `AdminAwdMatch` shape — eight characters of an id
310    under copy telling somebody to go and find it.
311    """
312
313    range: GauntletRangeInfo
314    task: AdminTaskInfo

The stored source, and the task that will build it.

⚠️ Both, because the two are the halves an organiser has to follow: the range is a thing that now exists in this deployment's records, and the build is something running that can fail. Answering with the task alone is the AdminAwdMatch shape — eight characters of an id under copy telling somebody to go and find it.

range: GauntletRangeInfo = PydanticUndefined
task: AdminTaskInfo = PydanticUndefined
class GauntletRangeInfo(ctfy.core.models.CtfyModel):
276class GauntletRangeInfo(CtfyModel):
277    """One range this deployment has a source for."""
278
279    id: str
280    #: Whether the estate is on this deployment's disk **right now**.
281    #:
282    #: ⚠️ Read off the catalog, never off the row. The row cannot say
283    #: whether the directory survived, and a stored status claiming to
284    #: would be a second answer that goes stale in silence — the shape
285    #: `registry_usage_scan` shipped with.
286    built: bool = False
287    corpus_commit: str = ""
288    created_by: str = ""
289    #: Who asked for it, resolved for display. ``""`` when the account is
290    #: gone — which, beside a non-empty ``created_by``, is a deleted
291    #: user rather than the platform's own.
292    created_by_display_name: str = ""
293    created_at_ts: float | None = None
294    rendered_at_ts: float | None = None
295    scenario: dict[str, Any] = Field(default_factory=dict)

One range this deployment has a source for.

id: str = PydanticUndefined
built: bool = False
corpus_commit: str = ''
created_by: str = ''
created_by_display_name: str = ''
created_at_ts: float | None = None
rendered_at_ts: float | None = None
scenario: dict[str, typing.Any] = PydanticUndefined
class GauntletRangeListResponse(ctfy.core.models.CtfyModel):
298class GauntletRangeListResponse(CtfyModel):
299    items: list[GauntletRangeInfo] = Field(default_factory=list)
300    total: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

items: list[GauntletRangeInfo] = PydanticUndefined
total: int = 0
class GauntletShortfall(ctfy.core.models.CtfyModel):
160class GauntletShortfall(CtfyModel):
161    """A tier the estate came up short of, and which cause it was."""
162
163    tier: str
164    missing: int = 0
165    #: ``library`` (the pool ran dry) or ``budget`` (every remaining
166    #: candidate was refused for room). ⚠️ Opposite causes with opposite
167    #: fixes, which is why the compiler records it as the loop runs:
168    #: afterwards a popped pool and an exhausted one look identical.
169    because: str = ""

A tier the estate came up short of, and which cause it was.

tier: str = PydanticUndefined
missing: int = 0
because: str = ''
class GrantAuthoringReviewerRequest(ctfy.core.models.CtfyModel):
1361class GrantAuthoringReviewerRequest(CtfyModel):
1362    """Body for ``POST /admin/competitions/{id}/authoring-reviewers`` —
1363    the target by id or (case-insensitive) email, the competition-admin
1364    grant's rule. No ``role``: this grant has exactly one."""
1365
1366    user_id: str = ""
1367    email: str = ""

Body for POST /admin/competitions/{id}/authoring-reviewers — the target by id or (case-insensitive) email, the competition-admin grant's rule. No role: this grant has exactly one.

user_id: str = ''
email: str = ''
class GrantCompetitionAdminRequest(ctfy.core.models.CtfyModel):
1370class GrantCompetitionAdminRequest(CtfyModel):
1371    """Body for ``PUT /admin/competitions/{id}/admins`` — resolve the
1372    target user by id or (case-insensitive) email.
1373
1374    ``role`` picks the grant tier: ``admin`` (full organiser control of
1375    this event) or ``reviewer`` (roster read + eligibility verdicts +
1376    export, nothing that removes a team). Defaults to ``admin`` so an
1377    older client that omits it keeps the prior behaviour.
1378
1379    ``reason`` is an optional organiser note carried onto the invitation
1380    row (and, for a fresh invitation, into the mail). Only the
1381    participation-invite routes read it; the admin-grant routes that
1382    share this body ignore it."""
1383
1384    user_id: str = ""
1385    email: str = ""
1386    role: CompetitionGrantRole = "admin"
1387    reason: str = Field(default="", max_length=500)

Body for PUT /admin/competitions/{id}/admins — resolve the target user by id or (case-insensitive) email.

role picks the grant tier: admin (full organiser control of this event) or reviewer (roster read + eligibility verdicts + export, nothing that removes a team). Defaults to admin so an older client that omits it keeps the prior behaviour.

reason is an optional organiser note carried onto the invitation row (and, for a fresh invitation, into the mail). Only the participation-invite routes read it; the admin-grant routes that share this body ignore it.

user_id: str = ''
email: str = ''
role: Literal['admin', 'reviewer'] = 'admin'
reason: str = ''
class HarnessModelCell(ctfy.core.models.CtfyModel):
171class HarnessModelCell(CtfyModel):
172    """One ``(model, harness)`` pair's solve stats — a cross-tab cell.
173
174    The apples-to-apples lens the pooled rollups can't give: holding the
175    model fixed and varying the harness isolates the harness's effect on
176    that model (e.g. ``gpt-5`` solves 80% under one harness, 60% under
177    another). Display labels are resolved frontend-side from the model /
178    harness standings, so the cell carries only ids + stats.
179    """
180
181    model_id: str = ""
182    #: harness registry id; blank = the built-in ctfy harness.
183    harness_id: str = ""
184    challenges_attempted: int = 0
185    challenges_solved: int = 0
186    solve_rate: float = 0.0

One (model, harness) pair's solve stats — a cross-tab cell.

The apples-to-apples lens the pooled rollups can't give: holding the model fixed and varying the harness isolates the harness's effect on that model (e.g. gpt-5 solves 80% under one harness, 60% under another). Display labels are resolved frontend-side from the model / harness standings, so the cell carries only ids + stats.

model_id: str = ''
harness_id: str = ''
challenges_attempted: int = 0
challenges_solved: int = 0
solve_rate: float = 0.0
class HarnessStanding(ctfy.core.models.CtfyModel):
139class HarnessStanding(CtfyModel):
140    """One harness's aggregate scorecard across all the models it drove.
141
142    The harness's runs are pooled across models: a challenge counts as
143    *solved* if **any** run under the harness solved it. Like the vendor
144    rollup this ranks harness *plus its model fleet*, not the harness in
145    isolation — a harness paired with stronger models scores higher.
146    """
147
148    rank: int = 0
149    #: harness registry id; blank = the built-in ctfy harness.
150    harness_id: str = ""
151    display_name: str = ""
152    #: distinct models evaluated under this harness.
153    models: int = 0
154    challenges_attempted: int = 0
155    challenges_solved: int = 0
156    solve_rate: float = 0.0
157    questions_solved: int = 0
158    total_tokens: int = 0
159    #: estimated USD cost summed across the harness's runs (0 when unpriced).
160    cost_usd: float = 0.0
161    #: Whose agent this is: author, version, source, digest.
162    #:
163    #: ⚠️ ``None`` for the platform's own harnesses — they have no owner,
164    #: so there is no author to link to. This board is *entirely* agents,
165    #: which is what makes the attribution matter more here than on a
166    #: mixed board: a comparison table whose subject is a slug compares
167    #: nothing a reader can go and check.
168    agent: AgentEntrantInfo | None = None

One harness's aggregate scorecard across all the models it drove.

The harness's runs are pooled across models: a challenge counts as solved if any run under the harness solved it. Like the vendor rollup this ranks harness plus its model fleet, not the harness in isolation — a harness paired with stronger models scores higher.

rank: int = 0
harness_id: str = ''
display_name: str = ''
models: int = 0
challenges_attempted: int = 0
challenges_solved: int = 0
solve_rate: float = 0.0
questions_solved: int = 0
total_tokens: int = 0
cost_usd: float = 0.0
agent: AgentEntrantInfo | None = None
class HarnessTags(ctfy.core.models.CtfyModel):
222class HarnessTags(CtfyModel):
223    """What a registry publishes under one harness image's repository.
224
225    ``image`` is echoed back because the caller asked by harness id and
226    the answer is about the *repository* that id currently points at —
227    without it, a reader cannot tell which reference was looked up.
228    """
229
230    image: str = ""
231    #: Tags, in the order the registry gave them, capped by
232    #: ``ctfy.server.registry_tags.MAX_TAGS``. Empty is a real answer: a
233    #: repository with nothing published.
234    tags: list[str] = Field(default_factory=list)

What a registry publishes under one harness image's repository.

image is echoed back because the caller asked by harness id and the answer is about the repository that id currently points at — without it, a reader cannot tell which reference was looked up.

image: str = ''
tags: list[str] = PydanticUndefined
class HarnessUpdate(ctfy.core.models.CtfyModel):
237class HarnessUpdate(CtfyModel):
238    """The fields an owner may change on their own registered agent.
239
240    ⚠️ **A dedicated model, never ``HarnessState``.** The registration
241    route takes the state model and then overwrites five fields it
242    refuses to read from the request — ``owner_user_id`` and
243    ``review_status`` among them, because a field a client can set is a
244    field a client can forge. An update route that took the same model
245    would have to repeat that list correctly forever; here the forbidden
246    fields are simply *unrepresentable*, so the next column added to
247    ``HarnessState`` is not silently editable.
248
249    ⚠️ **Every field optional, and absence means "leave it".** Read with
250    ``model_fields_set``, never ``is not None``: an editor that saves one
251    field at a time and an absent key read as an empty value blanks the
252    rest — the trap the registration-edit and changelog routes both
253    document.
254
255    ``source_url`` keeps its ``WebUrl`` type, so the allow-list that
256    guards every address reaching an ``href`` applies here too. It is
257    rendered on a public leaderboard.
258    """
259
260    display_name: str | None = None
261    #: ⚠️ Changing this resets review — see the route. It is the reason
262    #: the field is editable at all: without it a new version of an
263    #: agent means delete-and-re-register, which throws away the name,
264    #: the notes and the row's history along with the verdict.
265    image: str | None = None
266    version: str | None = None
267    notes: str | None = None
268    source_url: WebUrl | None = None
269    active: bool | None = None

The fields an owner may change on their own registered agent.

⚠️ A dedicated model, never HarnessState. The registration route takes the state model and then overwrites five fields it refuses to read from the request — owner_user_id and review_status among them, because a field a client can set is a field a client can forge. An update route that took the same model would have to repeat that list correctly forever; here the forbidden fields are simply unrepresentable, so the next column added to HarnessState is not silently editable.

⚠️ Every field optional, and absence means "leave it". Read with model_fields_set, never is not None: an editor that saves one field at a time and an absent key read as an empty value blanks the rest — the trap the registration-edit and changelog routes both document.

source_url keeps its WebUrl type, so the allow-list that guards every address reaching an href applies here too. It is rendered on a public leaderboard.

display_name: str | None = None
image: str | None = None
version: str | None = None
notes: str | None = None
source_url: Optional[Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x72ea04d2a980>)]] = None
active: bool | None = None
class HealthResponse(ctfy.core.models.CtfyModel):
19class HealthResponse(CtfyModel):
20    status: str = "ok"
21    hostname: str = ""
22    running_instances: int = 0
23    capacity: int = 0
24    # Server's installed ``ctfy`` version. Lets the SDK/CLI flag a
25    # client/server skew off the probe they already make — no extra
26    # round trip. Defaults to "" so older servers (and the model's own
27    # default construction) stay valid.
28    version: str = ""
29    # Deployment tier + label, carried on the same probe so the CLI can
30    # warn an operator they're pointed at a non-production deployment (and
31    # add friction to destructive commands on prod) without a second
32    # round trip. ``prod`` is the fail-safe default.
33    environment: Environment = "prod"
34    environment_label: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

status: str = 'ok'
hostname: str = ''
running_instances: int = 0
capacity: int = 0
version: str = ''
environment: Literal['dev', 'staging', 'prod'] = 'prod'
environment_label: str = ''
class ImageConsistencyRow(ctfy.core.models.CtfyModel):
131class ImageConsistencyRow(CtfyModel):
132    """One ``repository:tag`` the fleet disagrees on — the wire shape of
133    :class:`ctfy.core.image_consistency.ImageDivergence`.
134
135    ``by_digest`` maps each distinct digest to the node ids holding it, so
136    the admin page can render "s2, s3 hold ff2cb7e…; cb holds f5dcfb…"
137    without re-deriving anything. ``digest_count`` / ``node_count`` are
138    ``computed_field``s — this is a wire model reaching a client, the axis
139    :class:`~ctfy.core.state.models.TeamState`'s ``@property`` sits on the
140    other side of.
141    """
142
143    ref: str
144    by_digest: dict[str, list[str]]
145
146    @computed_field
147    @property
148    def digest_count(self) -> int:
149        return len(self.by_digest)
150
151    @computed_field
152    @property
153    def node_count(self) -> int:
154        return sum(len(nodes) for nodes in self.by_digest.values())

One repository:tag the fleet disagrees on — the wire shape of ctfy.core.image_consistency.ImageDivergence.

by_digest maps each distinct digest to the node ids holding it, so the admin page can render "s2, s3 hold ff2cb7e…; cb holds f5dcfb…" without re-deriving anything. digest_count / node_count are computed_fields — this is a wire model reaching a client, the axis ~ctfy.core.state.models.TeamState's @property sits on the other side of.

ref: str = PydanticUndefined
by_digest: dict[str, list[str]] = PydanticUndefined
digest_count: int
146    @computed_field
147    @property
148    def digest_count(self) -> int:
149        return len(self.by_digest)
node_count: int
151    @computed_field
152    @property
153    def node_count(self) -> int:
154        return sum(len(nodes) for nodes in self.by_digest.values())
class InboxAnnouncement(ctfy.core.models.CtfyModel):
265class InboxAnnouncement(CtfyModel):
266    """Announcement projected onto the Inbox surface.
267
268    Carries the same fields as :class:`AnnouncementInfo` plus a
269    per-user ``is_read`` flag derived from the
270    ``announcement_reads`` table. The frontend renders unread rows
271    prominently and lets the user mark them read via
272    ``POST /me/announcements/{id}/read``.
273    """
274
275    id: str
276    title: str
277    body: str
278    severity: AnnouncementSeverity
279    starts_at: datetime | None = None
280    ends_at: datetime | None = None
281    created_at: datetime | None = None
282    updated_at: datetime | None = None
283    created_by: str
284    created_by_name: str
285    is_read: bool = False

Announcement projected onto the Inbox surface.

Carries the same fields as AnnouncementInfo plus a per-user is_read flag derived from the announcement_reads table. The frontend renders unread rows prominently and lets the user mark them read via POST /me/announcements/{id}/read.

id: str = PydanticUndefined
title: str = PydanticUndefined
body: str = PydanticUndefined
severity: Literal['info', 'warning', 'critical'] = PydanticUndefined
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
created_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
created_by: str = PydanticUndefined
created_by_name: str = PydanticUndefined
is_read: bool = False
class InboxCaptainRequest(ctfy.core.models.CtfyModel):
218class InboxCaptainRequest(CtfyModel):
219    """Pending join request against any team the calling user
220    captains. Frontend renders an Approve / Reject pair pointing at
221    ``/competitions/{competition_id}/invites/{invite_id}/approve|reject``.
222    """
223
224    id: str
225    competition_id: str
226    competition_title: str
227    team_id: str
228    team_name: str
229    requester_user_id: str
230    requester_display_name: str
231    created_at: datetime | None = None

Pending join request against any team the calling user captains. Frontend renders an Approve / Reject pair pointing at /competitions/{competition_id}/invites/{invite_id}/approve|reject.

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
requester_user_id: str = PydanticUndefined
requester_display_name: str = PydanticUndefined
created_at: datetime.datetime | None = None
class InboxCompetitionInvite(ctfy.core.models.CtfyModel):
234class InboxCompetitionInvite(CtfyModel):
235    """A standing invitation to participate in a PRIVATE competition the
236    calling user has been granted but not yet joined.
237
238    Unlike the team invites above this isn't accept/decline — the
239    allowlist row is a standing permission, so the frontend renders a
240    "Register" CTA linking to ``/competitions/{competition_id}/team``.
241    Drops off the inbox once the user registers (or the comp ends / is
242    flipped back to public).
243    """
244
245    competition_id: str
246    competition_title: str
247    competition_phase: Literal["upcoming", "running", "past"]
248    invited_by_user_id: str = ""
249    invited_by_display_name: str = ""
250    invited_at: datetime | None = None
251    #: Why the "Register" CTA above cannot be offered yet, or ``""``.
252    #:
253    #: ⚠️ **The row stays on the inbox when this is set.** Filtering the
254    #: card out is the shorter fix and it tells the invitee nothing:
255    #: "invited, waiting on the organiser" and "never invited" would
256    #: render identically, and the first is the one somebody is about to
257    #: ask about. The card explains instead, and the button is what goes.
258    #:
259    #: ⚠️ **``""`` is the open answer**, so a server too old to send the
260    #: field leaves the button exactly where it was — every gate here
261    #: fails open, and a wrong "blocked" takes a live registration away.
262    blocked_reason: InvitationBlock = ""

A standing invitation to participate in a PRIVATE competition the calling user has been granted but not yet joined.

Unlike the team invites above this isn't accept/decline — the allowlist row is a standing permission, so the frontend renders a "Register" CTA linking to /competitions/{competition_id}/team. Drops off the inbox once the user registers (or the comp ends / is flipped back to public).

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_phase: Literal['upcoming', 'running', 'past'] = PydanticUndefined
invited_by_user_id: str = ''
invited_by_display_name: str = ''
invited_at: datetime.datetime | None = None
blocked_reason: Literal['', 'not_open_yet', 'closed'] = ''
class InboxIncomingInvite(ctfy.core.models.CtfyModel):
185class InboxIncomingInvite(CtfyModel):
186    """Pending direct invite where the calling user is the named target.
187
188    The frontend renders an Accept / Decline pair pointing at
189    ``/competitions/{competition_id}/invites/{invite_id}/accept|decline``.
190    """
191
192    id: str
193    competition_id: str
194    competition_title: str
195    team_id: str
196    team_name: str
197    captain_user_id: str
198    captain_display_name: str
199    created_at: datetime | None = None

Pending direct invite where the calling user is the named target.

The frontend renders an Accept / Decline pair pointing at /competitions/{competition_id}/invites/{invite_id}/accept|decline.

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
captain_user_id: str = PydanticUndefined
captain_display_name: str = PydanticUndefined
created_at: datetime.datetime | None = None
class InboxOutgoingRequest(ctfy.core.models.CtfyModel):
202class InboxOutgoingRequest(CtfyModel):
203    """Pending join request the calling user opened — surfaces so the
204    requester can see "did the captain see this yet?" without
205    refreshing the team page.
206    """
207
208    id: str
209    competition_id: str
210    competition_title: str
211    team_id: str
212    team_name: str
213    captain_user_id: str
214    captain_display_name: str
215    created_at: datetime | None = None

Pending join request the calling user opened — surfaces so the requester can see "did the captain see this yet?" without refreshing the team page.

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
captain_user_id: str = PydanticUndefined
captain_display_name: str = PydanticUndefined
created_at: datetime.datetime | None = None
class InboxResponse(ctfy.core.models.CtfyModel):
288class InboxResponse(CtfyModel):
289    """``GET /me/inbox`` payload — pending invites/requests +
290    actionable announcements grouped by the action the calling
291    user can take.
292    """
293
294    incoming_invites: list[InboxIncomingInvite] = Field(default_factory=list)
295    outgoing_requests: list[InboxOutgoingRequest] = Field(default_factory=list)
296    captain_requests: list[InboxCaptainRequest] = Field(default_factory=list)
297    # Standing participation invites to private competitions the user
298    # hasn't joined yet (rendered with a "Register" CTA, not accept/decline).
299    competition_invites: list[InboxCompetitionInvite] = Field(default_factory=list)
300    # Site-wide announcements relevant right now (currently live + a
301    # short tail of recently-expired) with the user's read state.
302    announcements: list[InboxAnnouncement] = Field(default_factory=list)
303    # Convenience count so the sidebar can render an unread badge
304    # without re-iterating ``announcements``.
305    unread_announcement_count: int = 0

GET /me/inbox payload — pending invites/requests + actionable announcements grouped by the action the calling user can take.

incoming_invites: list[InboxIncomingInvite] = PydanticUndefined
outgoing_requests: list[InboxOutgoingRequest] = PydanticUndefined
captain_requests: list[InboxCaptainRequest] = PydanticUndefined
competition_invites: list[InboxCompetitionInvite] = PydanticUndefined
announcements: list[InboxAnnouncement] = PydanticUndefined
unread_announcement_count: int = 0
class InstanceAnswersResponse(ctfy.core.models.CtfyModel):
356class InstanceAnswersResponse(CtfyModel):
357    """Every answer minted for one live instance.
358
359    ⚠️ **The one place the platform hands out groundtruth.** Everything
360    else is deliberately the other way round: the catalog redacts gated
361    prompts, ``AdminChallengeQuestions`` widens *who may read the
362    question* and says in its own docstring that it does not widen
363    *what is said*, and ``POST /admin/answer-trace`` attributes a value
364    the caller already holds without ever emitting one. This route
365    exists because an organiser debugging "the team swears they
366    submitted this" has no other way to compare, and it is scoped so
367    the cost of that is bounded and visible:
368
369    * **live instances only** — an archived record carries the digest,
370      not the plaintext, so there is nothing here to give out and the
371      route says so rather than returning an empty list that reads as
372      "no answers were minted";
373    * **one instance per request**, never a listing, so reading the
374      whole event is N deliberate acts rather than one page;
375    * **audited** — every read emits ``instance_answers_revealed``
376      naming the admin, and the row carries the count and no value.
377
378    A static ``answer:`` is not here either: it lives in
379    ``metadata.yaml`` and is the same for every team, so it is not a
380    per-instance fact and this is not the route that would answer it.
381    """
382
383    instance_id: str
384    team_id: str = ""
385    challenge_id: str = ""
386    answers: list[RevealedAnswer] = Field(default_factory=list)

Every answer minted for one live instance.

⚠️ The one place the platform hands out groundtruth. Everything else is deliberately the other way round: the catalog redacts gated prompts, AdminChallengeQuestions widens who may read the question and says in its own docstring that it does not widen what is said, and POST /admin/answer-trace attributes a value the caller already holds without ever emitting one. This route exists because an organiser debugging "the team swears they submitted this" has no other way to compare, and it is scoped so the cost of that is bounded and visible:

  • live instances only — an archived record carries the digest, not the plaintext, so there is nothing here to give out and the route says so rather than returning an empty list that reads as "no answers were minted";
  • one instance per request, never a listing, so reading the whole event is N deliberate acts rather than one page;
  • audited — every read emits instance_answers_revealed naming the admin, and the row carries the count and no value.

A static answer: is not here either: it lives in metadata.yaml and is the same for every team, so it is not a per-instance fact and this is not the route that would answer it.

instance_id: str = PydanticUndefined
team_id: str = ''
challenge_id: str = ''
answers: list[RevealedAnswer] = PydanticUndefined
class InstanceInfo(ctfy.core.models.CtfyModel):
 79class InstanceInfo(CtfyModel):
 80    id: str
 81    challenge_id: str = ""
 82    team_id: str = ""
 83    # Competition the instance is scoped to. Stamped from the resolved
 84    # per-comp team at start time; empty for legacy rows or unscoped
 85    # admin instances. Frontend keys per-comp instance pages off this
 86    # field so a user registered for two comps doesn't see comp A's
 87    # instances in comp B's tab.
 88    competition_id: str = ""
 89    # Worker node currently hosting this instance. Empty only for
 90    # records created before nodes were tracked (legacy).
 91    node_id: str = ""
 92    # Display names for the two ids above, so an admin table can render
 93    # what it links to rather than a truncated uuid. Resolved in bulk at
 94    # the route from a pre-loaded map; ``""`` means "not resolved", which
 95    # is what a caller that passes no map gets and what the client
 96    # already renders today (the id, truncated).
 97    #
 98    # ⚠️ Empty is never the id. `EntityCell` branches on name-vs-id to
 99    # decide whether it has something human to show, so echoing the id
100    # here would make "we know the name" indistinguishable from "we
101    # don't" — and every row would render as though it had been resolved.
102    team_name: str = ""
103    node_name: str = ""
104    name: str = ""
105    category: ChallengeCategory | None = None
106    difficulty: str = ""
107    status: InstanceStatus = InstanceStatus.STARTING
108    started_at: float = 0.0
109    ttl: int = 0
110    expires_at: float = 0.0
111    services: list[ServiceEndpoint] = Field(default_factory=list)
112    description: str = ""
113    # Per-instance question view: same shape as ``ChallengeInfo.questions``
114    # but additionally carries the agent-visible state. ``unlocked`` is
115    # ``False`` when the question's ``requires:`` predecessors haven't
116    # all been answered correctly yet; the UI hides the prompt for locked
117    # questions to avoid leaking route hints. ``answered_correctly`` is
118    # ``True`` once this team has captured this question on this instance.
119    questions: list[InstanceQuestionInfo] = Field(default_factory=list)
120    # The files *this* instance offers its team: the node's scan of the
121    # instance's own ``attachments/`` after templates rendered and the
122    # ``attachment-builder`` ran (``surface.provided_files``). Per-team
123    # artifacts appear here, a placeholder the builder deleted does not,
124    # and each downloads from ``/instances/{id}/attachments/{name}``.
125    # Empty until the instance is ready (#1933). Pre-launch, the
126    # challenge's ``ChallengeInfo.attachments`` is the catalog's listing.
127    attachments: list[AttachmentInfo] = Field(default_factory=list)
128    # How the platform exposes this instance to the player. Always
129    # surfaced (defaults to ``"simple"``) so the agent can branch on
130    # `if info.network_topology == "engagement": ...` without dealing
131    # with a missing field.
132    network_topology: Literal["simple", "engagement"] = "simple"
133    # Which VPN an engagement instance runs. Meaningless in simple
134    # mode, where it stays at the default. Surfaced so the UI can name
135    # the right client command and an agent can branch without
136    # downloading the config to find out what it is.
137    vpn_backend: Literal["openvpn", "wireguard"] = "openvpn"
138    # Set only when ``network_topology == "engagement"``. Carries the
139    # tunnel endpoint host:port + the URL the client downloads the
140    # config body from. ``None`` in simple mode.
141    vpn_endpoint: VpnEndpoint | None = None
142    # Landing-page hints declared in the challenge's ``metadata.yaml``
143    # (``entry_urls:`` field, validated by META013). Surfaced verbatim
144    # to the frontend so the VPN / services panel can render a
145    # "start here" list without the player having to read the
146    # description for the canonical first URL.
147    entry_urls: list[str] = Field(default_factory=list)
148    # Which supply this box came from. ``on_demand`` is a player's own
149    # launch and behaves the way every instance always has; ``match`` is
150    # an arena gamebox an organiser provisioned, and it is a genuinely
151    # different object to its owner — it never expires, stopping it is
152    # not theirs to do, and it is the one the round loop rotates flags
153    # into. The client cannot tell them apart from `challenge_id` alone,
154    # because a player may also launch the gamebox on demand.
155    lease: Literal["on_demand", "match"] = "on_demand"

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
challenge_id: str = ''
team_id: str = ''
competition_id: str = ''
node_id: str = ''
team_name: str = ''
node_name: str = ''
name: str = ''
category: ctfy.challenge.benchmark.ChallengeCategory | None = None
difficulty: str = ''
status: ctfy.core.enums.InstanceStatus = <InstanceStatus.STARTING: 'starting'>
started_at: float = 0.0
ttl: int = 0
expires_at: float = 0.0
services: list[ctfy.core.target.ServiceEndpoint] = PydanticUndefined
description: str = ''
questions: list[InstanceQuestionInfo] = PydanticUndefined
attachments: list[ctfy.core.target.AttachmentInfo] = PydanticUndefined
network_topology: Literal['simple', 'engagement'] = 'simple'
vpn_backend: Literal['openvpn', 'wireguard'] = 'openvpn'
vpn_endpoint: ctfy.core.target.VpnEndpoint | None = None
entry_urls: list[str] = PydanticUndefined
lease: Literal['on_demand', 'match'] = 'on_demand'
class InstanceQuestionInfo(ctfy.core.models.CtfyModel):
34class InstanceQuestionInfo(CtfyModel):
35    """One question, projected for a specific running instance.
36
37    Mirrors :class:`QuestionPublicInfo` plus per-instance state.
38
39    ⚠️ **A question the calling team can neither see nor has captured is
40    absent from the list entirely**, rather than present with a blanked
41    ``prompt`` — see ``instances._project_instance_questions`` for why
42    the id, the ``requires`` edges, ``choices`` and the row *count* are
43    each disclosure in their own right. So ``unlocked`` is ``False`` on
44    exactly one shape: a question the team has already captured whose
45    predecessor they never formally answered (a report-credited
46    sentinel), where the prompt stays empty and the row stays because it
47    is a fact about their own work.
48    """
49
50    id: str
51    prompt: str
52    mode: str | None = None
53    choices: list[str] | None = None
54    requires: list[str] = Field(default_factory=list)
55    # ``True`` once every id in ``requires`` has been captured by the
56    # calling team on this instance. ``False`` keeps the prompt empty
57    # to avoid leaking the question text early.
58    unlocked: bool = True
59    # ``True`` once the calling team has submitted a correct answer
60    # for this question against this instance. Drives the UI checkmark.
61    answered_correctly: bool = False
62    # Wrong-attempt budget remaining for the calling team on this
63    # specific question. ``None`` when the question's mode is uncapped
64    # (dynamic free-form, or any mode the operator opted out of via
65    # ``question_attempt_caps``). The UI renders an ``X/N attempts``
66    # badge from this on first page load and decrements it locally on
67    # each wrong submit (the submission response also carries it).
68    attempts_remaining: int | None = None
69    # The cap that ``attempts_remaining`` is being measured against —
70    # echoed so the UI can render ``X/N`` without recomputing N from
71    # the question's mode + current platform settings.
72    attempts_cap: int | None = None
73    # ``True`` for a checker question whose ``check.poll`` is on: the
74    # platform auto-verifies it in the background, so the UI shows an
75    # "auto-verify on" affordance. Never exposes the checker command.
76    auto_check: bool = False

One question, projected for a specific running instance.

Mirrors QuestionPublicInfo plus per-instance state.

⚠️ A question the calling team can neither see nor has captured is absent from the list entirely, rather than present with a blanked prompt — see instances._project_instance_questions for why the id, the requires edges, choices and the row count are each disclosure in their own right. So unlocked is False on exactly one shape: a question the team has already captured whose predecessor they never formally answered (a report-credited sentinel), where the prompt stays empty and the row stays because it is a fact about their own work.

id: str = PydanticUndefined
prompt: str = PydanticUndefined
mode: str | None = None
choices: list[str] | None = None
requires: list[str] = PydanticUndefined
unlocked: bool = True
answered_correctly: bool = False
attempts_remaining: int | None = None
attempts_cap: int | None = None
auto_check: bool = False
class InstanceRecordArtifacts(ctfy.core.models.CtfyModel):
213class InstanceRecordArtifacts(CtfyModel):
214    """Per-artifact presence flags + counts for an archived instance.
215
216    Returned alongside the full :class:`InstanceRecord` from
217    ``GET /admin/instance-records/{id}`` so the admin UI can show
218    ``Manifest / Events / Submissions / Container log`` tabs at a glance
219    without a round-trip per tab.
220    """
221
222    has_manifest: bool = False
223    has_container_log: bool = False
224    events_count: int = 0
225    submissions_count: int = 0
226    traffic_count: int = 0
227    # Bytes on disk for the archived ``traffic.pcap``. 0 when no capture
228    # was persisted (sidecar disabled, archive disabled, fetch failed —
229    # or retention reclaimed it, which the two fields below say). The
230    # admin UI uses this both to gate the download button and to render
231    # an "X MB" hint next to it.
232    pcap_bytes: int = 0
233    #: When retention removed this archive's capture files (#1714), or
234    #: ``0`` if it never did. ⚠️ Why the counts above can be zero on an
235    #: instance that was captured: a zero beside a non-zero here is
236    #: *reclaimed*, not *never captured*.
237    capture_reclaimed_at: float = 0.0
238    capture_reclaimed_bytes: int = 0
239    #: When retention removed the rest (manifest, events, submissions,
240    #: container log), or ``0``. The ``InstanceRecord`` row is never
241    #: removed, so the record above stays whole either way.
242    archive_reclaimed_at: float = 0.0
243    archive_reclaimed_bytes: int = 0
244    #: What the node's per-instance capture cap dropped before the
245    #: capture was archived — the oldest segments. ``0`` = nothing
246    #: recorded.
247    pcap_dropped_bytes: int = 0
248    pcap_dropped_segments: int = 0

Per-artifact presence flags + counts for an archived instance.

Returned alongside the full InstanceRecord from GET /admin/instance-records/{id} so the admin UI can show Manifest / Events / Submissions / Container log tabs at a glance without a round-trip per tab.

has_manifest: bool = False
has_container_log: bool = False
events_count: int = 0
submissions_count: int = 0
traffic_count: int = 0
pcap_bytes: int = 0
capture_reclaimed_at: float = 0.0
capture_reclaimed_bytes: int = 0
archive_reclaimed_at: float = 0.0
archive_reclaimed_bytes: int = 0
pcap_dropped_bytes: int = 0
pcap_dropped_segments: int = 0
class InstanceRecordDetail(ctfy.core.models.CtfyModel):
310class InstanceRecordDetail(CtfyModel):
311    record: InstanceRecordInfoDetail
312    artifacts: InstanceRecordArtifacts = Field(default_factory=InstanceRecordArtifacts)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

record: InstanceRecordInfoDetail = PydanticUndefined
artifacts: InstanceRecordArtifacts = PydanticUndefined
class InstanceRecordInfo(ctfy.core.models.CtfyModel):
158class InstanceRecordInfo(CtfyModel):
159    """Archived-instance summary for the admin history list.
160
161    Mirrors :class:`ctfy.core.state.models.InstanceRecord` minus the heavy
162    fields (``spec``, ``surface``). Used by ``GET /admin/instance-records``.
163    """
164
165    id: str
166    team_id: str = ""
167    challenge_id: str = ""
168    node_id: str = ""
169    # Same contract as ``InstanceInfo.team_name`` / ``node_name`` — see
170    # there. Declared again rather than inherited because this is a
171    # sibling class, not a subclass, so a field added to one is genuinely
172    # absent from the other. The archive needs them at least as much as
173    # the live table: it is read after the event, when the team it names
174    # is likeliest to have been deleted.
175    team_name: str = ""
176    node_name: str = ""
177    name: str = ""
178    category: ChallengeCategory | None = None
179    difficulty: str = ""
180    status: ArchivedInstanceStatus = ""
181    stop_reason: str = ""
182    error: str = ""
183    # Full node diagnostic (e.g. ``docker compose up`` stderr tail).
184    # Admin-only: this model backs ``/admin/instance-records*`` only;
185    # the player-facing ``InstanceInfo`` / ``InstanceStatusResponse``
186    # deliberately omit it.
187    error_detail: str = ""
188    started_at: float = 0.0
189    stopped_at: float = 0.0
190    duration_s: float = 0.0
191    ttl: int = 0
192    solved: bool = False
193    attempts: int = 0
194    # Total HTTP requests captured by the mitmproxy sidecar during the
195    # instance's lifetime. 0 when the archive sink was disabled or no
196    # traffic was captured.
197    request_count: int = 0

Archived-instance summary for the admin history list.

Mirrors ctfy.core.state.models.InstanceRecord minus the heavy fields (spec, surface). Used by GET /admin/instance-records.

id: str = PydanticUndefined
team_id: str = ''
challenge_id: str = ''
node_id: str = ''
team_name: str = ''
node_name: str = ''
name: str = ''
category: ctfy.challenge.benchmark.ChallengeCategory | None = None
difficulty: str = ''
status: Union[ctfy.core.enums.InstanceStatus, Literal['']] = ''
stop_reason: str = ''
error: str = ''
error_detail: str = ''
started_at: float = 0.0
stopped_at: float = 0.0
duration_s: float = 0.0
ttl: int = 0
solved: bool = False
attempts: int = 0
request_count: int = 0
class InstanceRecordInfoDetail(ctfy.server.models.InstanceRecordInfo):
200class InstanceRecordInfoDetail(InstanceRecordInfo):
201    """Full record — includes spec + surface, returned by detail endpoint."""
202
203    spec: dict[str, Any] = Field(default_factory=dict)
204    surface: AttackSurface | None = None
205    artifact_dir: str = ""
206    #: Where the start took its built services from (#1852): ``frozen``,
207    #: ``built: <why the frozen images were not used>``, or ``""``. Read
208    #: off the *live* instance; an archived record leaves it ``""``, which
209    #: is "not recorded", never "built".
210    image_source: str = ""

Full record — includes spec + surface, returned by detail endpoint.

spec: dict[str, typing.Any] = PydanticUndefined
surface: ctfy.core.target.AttackSurface | None = None
artifact_dir: str = ''
image_source: str = ''
class InstanceStatusResponse(ctfy.core.models.CtfyModel):
412class InstanceStatusResponse(CtfyModel):
413    id: str
414    status: InstanceStatus = InstanceStatus.STARTING
415    attack_surface: AttackSurface | None = None
416    error: str = ""
417    # CA volume for the per-instance mitmproxy. Operators trust this CA
418    # if they want to MITM HTTPS; HTTP traffic is captured transparently
419    # without it.
420    cert_volume: str = ""
421    #: Seconds this platform will keep waiting for a start before it
422    #: calls the instance ERROR — so a client waiting for readiness can
423    #: adopt the deadline instead of inventing a shorter one.
424    #: ⚠️ ``0`` means *this server did not say* (it predates the field),
425    #: never *no time at all*: a client reading it as a deadline would
426    #: give up instantly on every older deployment.
427    start_budget_s: int = 0

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
status: ctfy.core.enums.InstanceStatus = <InstanceStatus.STARTING: 'starting'>
attack_surface: ctfy.core.target.AttackSurface | None = None
error: str = ''
cert_volume: str = ''
start_budget_s: int = 0
class InstanceStorageReport(ctfy.core.models.CtfyModel):
293class InstanceStorageReport(CtfyModel):
294    """``GET /admin/instance-storage``: both per-instance stores on disk.
295
296    ⚠️ **Two stores on possibly two hosts, so two sources.** The archive
297    is measured by the platform on its own disk; each node measures its
298    own captures and sends the reading on its heartbeat. A node whose row
299    has no ``report`` has not been measured — a different fact from an
300    empty store, and the page says which.
301    """
302
303    archive: ArchiveStoreReport | None = None
304    captures: list[NodeCaptureStoreRow] = Field(default_factory=list)
305    capture_retention_s: int = 0
306    archive_retention_s: int = 0
307    capture_max_bytes: int = 0

GET /admin/instance-storage: both per-instance stores on disk.

⚠️ Two stores on possibly two hosts, so two sources. The archive is measured by the platform on its own disk; each node measures its own captures and sends the reading on its heartbeat. A node whose row has no report has not been measured — a different fact from an empty store, and the page says which.

archive: ArchiveStoreReport | None = None
captures: list[NodeCaptureStoreRow] = PydanticUndefined
capture_retention_s: int = 0
archive_retention_s: int = 0
capture_max_bytes: int = 0
class InstanceTrafficPage(ctfy.core.models.CtfyModel):
480class InstanceTrafficPage(CtfyModel):
481    """One poll's worth of captured flows, plus where to resume.
482
483    ``cursor`` is opaque: it is this platform's private encoding of how
484    far each capture file has been read, and nothing outside
485    ``ctfy.server.traffic_tail`` may parse it. A client stores whatever
486    it was handed and sends it back.
487
488    ⚠️ **An empty cursor means "start over", so a client must not invent
489    one.** Sending `""` re-reads the whole capture — which is exactly
490    the cost the tail read exists to remove, arriving through the client
491    rather than through the server.
492    """
493
494    flows: FlowRecords = Field(default_factory=list)
495    cursor: str = ""
496    # A bound was hit and more is already on disk. A client catching up
497    # should poll again immediately rather than waiting out its
498    # interval — the alternative is a defender watching an attack
499    # through a window that fills at one page per interval.
500    truncated: bool = False
501    #: ``""`` / ``"on"`` / ``"off"`` — whether a proxy sidecar exists for
502    #: this instance at all.
503    #:
504    #: ⚠️ **Tri-state, and ``""`` is the default, because the other two
505    #: values are claims.** An empty page means "nothing yet" when a
506    #: sidecar is running and "nothing, ever" when none is, and those
507    #: render as the same empty table — which reads as the feature being
508    #: broken. But a node that never answered has told us neither, and a
509    #: boolean has nowhere to put that. Same rule as
510    #: ``ScoreboardEntry.review_status`` defaulting to ``""`` rather
511    #: than to ``approved``.
512    capture: str = ""
513    #: What redaction did to the flows above, for the surface that
514    #: composed them.
515    #:
516    #: ⚠️ **``""`` is *this surface did not say*, and it is the default
517    #: because the other three are claims.** `raw_traffic_page` is the
518    #: forensic record and redacts nothing on purpose, so
519    #: ``nothing_to_redact`` there would be false in the costly
520    #: direction — it is not that there was nothing to remove, it is
521    #: that this surface does not remove it. The failure paths in
522    #: `instance_traffic._page` never reached a redactor at all and say
523    #: the same. Same reading as ``capture`` above, and
524    #: ``MyEvalRunRow.usage_source``'s recorded correction.
525    #:
526    #: ⚠️ **A client reads ``unscrubbable`` positively** — an older
527    #: server sends no field at all, which must not read as a refusal.
528    redaction: RedactionState = ""
529    #: Which secrets the floor could not replace, **by name**: a
530    #: question id, or a family's name. ⚠️ Never the values — a refusal
531    #: that quotes the secret is the leak it refused to commit.
532    #: Non-empty only beside ``redaction == "unscrubbable"``, and it is
533    #: what makes that refusal actionable rather than a dead end.
534    unscrubbable: list[str] = Field(default_factory=list)

One poll's worth of captured flows, plus where to resume.

cursor is opaque: it is this platform's private encoding of how far each capture file has been read, and nothing outside ctfy.server.traffic_tail may parse it. A client stores whatever it was handed and sends it back.

⚠️ An empty cursor means "start over", so a client must not invent one. Sending "" re-reads the whole capture — which is exactly the cost the tail read exists to remove, arriving through the client rather than through the server.

flows: list[dict[str, typing.Any]] = PydanticUndefined
cursor: str = ''
truncated: bool = False
capture: str = ''
redaction: Union[Literal[''], Literal['nothing_to_redact', 'redacted', 'unscrubbable']] = ''
unscrubbable: list[str] = PydanticUndefined
class InstanceTrafficWitnesses(ctfy.core.models.CtfyModel):
459class InstanceTrafficWitnesses(CtfyModel):
460    """One poll's worth of witnesses, plus where to resume.
461
462    Same cursor as :class:`InstanceTrafficPage` — a per-file byte
463    offset, opaque, and the two readers advance independently because
464    each holds its own.
465    """
466
467    witnesses: list[TrafficWitness] = Field(default_factory=list)
468    cursor: str = ""
469    #: ⚠️ **A ceiling was hit, so this answer is incomplete rather than
470    #: empty**, and those are opposite advice about a capture. The round
471    #: loop reads it as *unknown*; reading it as "nobody was there"
472    #: would manufacture an accusation out of a budget.
473    truncated: bool = False
474    #: ``""`` / ``"on"`` / ``"off"``, exactly as on the flow page and
475    #: for the same reason: "quiet" and "nothing is watching" render as
476    #: the same empty answer, and only the node can tell them apart.
477    capture: str = ""

One poll's worth of witnesses, plus where to resume.

Same cursor as InstanceTrafficPage — a per-file byte offset, opaque, and the two readers advance independently because each holds its own.

witnesses: list[TrafficWitness] = PydanticUndefined
cursor: str = ''
truncated: bool = False
capture: str = ''
class InstitutionCreate(ctfy.core.models.CtfyModel):
 83class InstitutionCreate(CtfyModel):
 84    """Body for creating or replacing one row.
 85
 86    ⚠️ **``domain`` is the identity, so this is an upsert rather than a
 87    POST-then-PATCH pair.** Renaming one is a delete and a create, which
 88    is what the key being the name means; a PATCH that could move it
 89    would be a second way to reach a decision the delete makes
 90    deliberate.
 91
 92    ⚠️ **``url`` is ``WebUrl``**, the allow-list type, because it reaches
 93    an ``href`` on a page anybody can open.
 94    """
 95
 96    domain: str = Field(min_length=1, max_length=253)
 97    names: dict[str, str] = Field(default_factory=dict)
 98    short_name: str = Field(default="", max_length=32)
 99    kind: InstitutionKind = "other"
100    url: WebUrl | None = None

Body for creating or replacing one row.

⚠️ domain is the identity, so this is an upsert rather than a POST-then-PATCH pair. Renaming one is a delete and a create, which is what the key being the name means; a PATCH that could move it would be a second way to reach a decision the delete makes deliberate.

⚠️ url is WebUrl, the allow-list type, because it reaches an href on a page anybody can open.

domain: str = PydanticUndefined
names: dict[str, str] = PydanticUndefined
short_name: str = ''
kind: Literal['university', 'company', 'other'] = 'other'
url: Optional[Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x72ea04d2a980>)]] = None
class InstitutionInfo(ctfy.core.models.CtfyModel):
17class InstitutionInfo(CtfyModel):
18    """One named domain, as an operator's page reads it.
19
20    ⚠️ **``names`` crosses as a map, resolved in the browser.** Copy is
21    localised and the language is a client-side preference switched
22    without a reload — the changelog's decision, for the same reason: a
23    server that picked one would publish whichever language the request
24    happened to carry into a table the operator curates in both.
25    """
26
27    domain: str = ""
28    names: dict[str, str] = Field(default_factory=dict)
29    short_name: str = ""
30    kind: InstitutionKind = "other"
31    url: str = ""
32    created_at: AwareDatetime | None = None
33    updated_at: AwareDatetime | None = None
34    #: How many accounts hold a **proved** address here.
35    #:
36    #: ⚠️ **Composed by the route, never stored**: it is a measurement of
37    #: the deployment's own people and would be stale the moment anybody
38    #: verified an address. `InstitutionSuggestion.holders` is the same
39    #: number over the domains nobody has named, which is why they share
40    #: one producer and one query.
41    #:
42    #: ⚠️ **``0`` is a real answer** — "this domain is named and nobody
43    #: here is at it" is exactly what an operator who over-named wants to
44    #: see. The cost of that decision is that a producer which forgets to
45    #: fill it publishes the same ``0``; it is the direction somebody
46    #: reports, on a row whose whole point is a count.
47    holders: int = 0
48
49    @computed_field
50    @property
51    def warning(self) -> str:
52        """Why this stored domain is probably a mistake, or ``""``.
53
54        ⚠️ **Derived rather than stored, and it stays on the row rather
55        than being said once at the write.** `domain_warning` is
56        advisory by construction — a deployment run by one university
57        may mean ``edu.cn`` exactly as typed — so refusing it is wrong
58        and saying it only in the create response means an operator who
59        made the mistake last month never hears about it. A
60        `computed_field` is the wire's half of that decision; this is a
61        wire model, so the state model beside it deliberately has no
62        such column.
63        """
64        return domain_warning(self.domain)

One named domain, as an operator's page reads it.

⚠️ names crosses as a map, resolved in the browser. Copy is localised and the language is a client-side preference switched without a reload — the changelog's decision, for the same reason: a server that picked one would publish whichever language the request happened to carry into a table the operator curates in both.

domain: str = ''
names: dict[str, str] = PydanticUndefined
short_name: str = ''
kind: Literal['university', 'company', 'other'] = 'other'
url: str = ''
created_at: pydantic.types.AwareDatetime | None = None
updated_at: pydantic.types.AwareDatetime | None = None
holders: int = 0
warning: str
49    @computed_field
50    @property
51    def warning(self) -> str:
52        """Why this stored domain is probably a mistake, or ``""``.
53
54        ⚠️ **Derived rather than stored, and it stays on the row rather
55        than being said once at the write.** `domain_warning` is
56        advisory by construction — a deployment run by one university
57        may mean ``edu.cn`` exactly as typed — so refusing it is wrong
58        and saying it only in the create response means an operator who
59        made the mistake last month never hears about it. A
60        `computed_field` is the wire's half of that decision; this is a
61        wire model, so the state model beside it deliberately has no
62        such column.
63        """
64        return domain_warning(self.domain)

Why this stored domain is probably a mistake, or "".

⚠️ Derived rather than stored, and it stays on the row rather than being said once at the write. domain_warning is advisory by construction — a deployment run by one university may mean edu.cn exactly as typed — so refusing it is wrong and saying it only in the create response means an operator who made the mistake last month never hears about it. A computed_field is the wire's half of that decision; this is a wire model, so the state model beside it deliberately has no such column.

class InstitutionPage(ctfy.core.models.CtfyModel):
67class InstitutionPage(CtfyModel):
68    """Every named domain.
69
70    ⚠️ **Unpaged, and that is why it carries no ``limit``.** The
71    population is what one operator typed for one deployment and the
72    page is a table they curate, not a feed — `/admin/changelog`'s
73    reasoning. A bare list route that *did* take a window would owe a
74    total, which is what `test_a_list_route_says_how_many` is for; this
75    one takes none, so the count is here for the heading rather than to
76    say whether the answer was cut.
77    """
78
79    items: list[InstitutionInfo] = Field(default_factory=list)
80    total: int = 0

Every named domain.

⚠️ Unpaged, and that is why it carries no limit. The population is what one operator typed for one deployment and the page is a table they curate, not a feed — /admin/changelog's reasoning. A bare list route that did take a window would owe a total, which is what test_a_list_route_says_how_many is for; this one takes none, so the count is here for the heading rather than to say whether the answer was cut.

items: list[InstitutionInfo] = PydanticUndefined
total: int = 0
class InstitutionSuggestion(ctfy.core.models.CtfyModel):
103class InstitutionSuggestion(CtfyModel):
104    """A domain this deployment's people hold proved addresses at.
105
106    ⚠️ **Raw, not registrable.** Where the registrable boundary falls is
107    exactly what nothing here can compute without a Public Suffix List,
108    so ``mails.tsinghua.edu.cn`` and ``tsinghua.edu.cn`` arrive
109    separately and the operator names the suffix they mean — after which
110    the matcher covers both and neither is suggested again.
111    """
112
113    domain: str = ""
114    #: How many accounts hold a proved address there.
115    holders: int = 0
116
117    @computed_field
118    @property
119    def warning(self) -> str:
120        """``"public_suffix"`` where naming this would be too wide.
121
122        The same derivation `InstitutionInfo.warning` publishes, read
123        here *before* the operator commits rather than after — which is
124        the moment it can still change the answer. One producer
125        (`domain_warning`); two models compute it because each holds its
126        own domain, and a shared base would make the *suggestion* carry
127        a stored row's fields.
128        """
129        return domain_warning(self.domain)

A domain this deployment's people hold proved addresses at.

⚠️ Raw, not registrable. Where the registrable boundary falls is exactly what nothing here can compute without a Public Suffix List, so mails.tsinghua.edu.cn and tsinghua.edu.cn arrive separately and the operator names the suffix they mean — after which the matcher covers both and neither is suggested again.

domain: str = ''
holders: int = 0
warning: str
117    @computed_field
118    @property
119    def warning(self) -> str:
120        """``"public_suffix"`` where naming this would be too wide.
121
122        The same derivation `InstitutionInfo.warning` publishes, read
123        here *before* the operator commits rather than after — which is
124        the moment it can still change the answer. One producer
125        (`domain_warning`); two models compute it because each holds its
126        own domain, and a shared base would make the *suggestion* carry
127        a stored row's fields.
128        """
129        return domain_warning(self.domain)

"public_suffix" where naming this would be too wide.

The same derivation InstitutionInfo.warning publishes, read here before the operator commits rather than after — which is the moment it can still change the answer. One producer (domain_warning); two models compute it because each holds its own domain, and a shared base would make the suggestion carry a stored row's fields.

class InstitutionSuggestionPage(ctfy.core.models.CtfyModel):
132class InstitutionSuggestionPage(CtfyModel):
133    """A window onto the suggestions, with how many there were.
134
135    ⚠️ **The total is not decoration**: this route takes a ``limit``, so
136    a bare list could not tell a complete answer from a truncated one —
137    and "20 of 137 domains nobody has named" is the sentence that tells
138    an operator whether they are nearly done.
139    """
140
141    items: list[InstitutionSuggestion] = Field(default_factory=list)
142    total: int = 0

A window onto the suggestions, with how many there were.

⚠️ The total is not decoration: this route takes a limit, so a bare list could not tell a complete answer from a truncated one — and "20 of 137 domains nobody has named" is the sentence that tells an operator whether they are nearly done.

items: list[InstitutionSuggestion] = PydanticUndefined
total: int = 0
class LeaderboardCell(ctfy.core.models.CtfyModel):
28class LeaderboardCell(CtfyModel):
29    """One model's solve stats within one dimension bucket (e.g. difficulty=hard)."""
30
31    key: str = ""
32    attempted: int = 0
33    solved: int = 0
34    solve_rate: float = 0.0

One model's solve stats within one dimension bucket (e.g. difficulty=hard).

key: str = ''
attempted: int = 0
solved: int = 0
solve_rate: float = 0.0
LimitOffsetPageUpTo200 = <class 'ctfy.server.models.pagination.LimitOffsetPageCustomized'>
LimitOffsetPageUpTo500 = <class 'ctfy.server.models.pagination.LimitOffsetPageCustomized'>
class LinkStartResponse(ctfy.core.models.CtfyModel):
620class LinkStartResponse(CtfyModel):
621    """Returned by ``POST /auth/identities/link/{provider}``.
622
623    The caller is already authenticated via XHR, so we don't 302 them;
624    instead we hand back the authorize URL for the frontend to navigate
625    the browser to.
626    """
627
628    authorize_url: str

Returned by POST /auth/identities/link/{provider}.

The caller is already authenticated via XHR, so we don't 302 them; instead we hand back the authorize URL for the frontend to navigate the browser to.

authorize_url: str = PydanticUndefined
class LinkedIdentity(ctfy.core.models.CtfyModel):
77class LinkedIdentity(CtfyModel):
78    id: str
79    # "github" | "google" (OAuth) or "password" for a local credential.
80    provider: str
81    provider_email: str = ""
82    provider_display_name: str = ""
83    # Stable third-party account id (GitHub numeric id, Google `sub`);
84    # empty for the local password credential.
85    provider_user_id: str = ""
86    # GitHub `@handle`; empty for Google and password.
87    provider_login: str = ""
88    avatar_url: str = ""
89    created_at: datetime | None = None
90    last_used_at: datetime | None = None

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
provider: str = PydanticUndefined
provider_email: str = ''
provider_display_name: str = ''
provider_user_id: str = ''
provider_login: str = ''
avatar_url: str = ''
created_at: datetime.datetime | None = None
last_used_at: datetime.datetime | None = None
class LlmBudgetPage(ctfy.core.models.CtfyModel):
840class LlmBudgetPage(CtfyModel):
841    """The organiser's LLM-spend roster.
842
843    ``gateway_configured`` is on the envelope for the reason
844    ``webhook_configured`` is on the email-suppression list: an empty
845    table otherwise reads as "nobody has spent anything" when it means
846    "this deployment enforces no budgets at all", and those call for
847    opposite actions.
848    """
849
850    items: list[LlmBudgetRow]
851    total: int
852    gateway_configured: bool

The organiser's LLM-spend roster.

gateway_configured is on the envelope for the reason webhook_configured is on the email-suppression list: an empty table otherwise reads as "nobody has spent anything" when it means "this deployment enforces no budgets at all", and those call for opposite actions.

items: list[LlmBudgetRow] = PydanticUndefined
total: int = PydanticUndefined
gateway_configured: bool = PydanticUndefined
class LlmBudgetResetInfo(ctfy.core.models.CtfyModel):
565class LlmBudgetResetInfo(CtfyModel):
566    """Response from the LLM-budget reset endpoint.
567
568    ⚠️ ``outcome`` is the point of the model. A reset touches two stores
569    and can legitimately land as *nothing to reset*; reporting only
570    ``200`` would make "the team has a fresh allowance" and "the gateway
571    is unreachable so nothing changed" the same reply, which is exactly
572    the failure the seam is written to avoid.
573    """
574
575    team_id: str
576    competition_id: str
577    challenge_id: str
578    outcome: str
579    reason: str

Response from the LLM-budget reset endpoint.

⚠️ outcome is the point of the model. A reset touches two stores and can legitimately land as nothing to reset; reporting only 200 would make "the team has a fresh allowance" and "the gateway is unreachable so nothing changed" the same reply, which is exactly the failure the seam is written to avoid.

team_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
outcome: str = PydanticUndefined
reason: str = PydanticUndefined
class LlmBudgetResetRequest(ctfy.core.models.CtfyModel):
555class LlmBudgetResetRequest(CtfyModel):
556    """Request body for ``POST /admin/teams/.../llm-budget/.../reset``.
557
558    ``reason`` is free-form audit metadata, capped like its
559    question-attempt twin so it round-trips through the SSE frame.
560    """
561
562    reason: str = Field(default="", max_length=500)

Request body for POST /admin/teams/.../llm-budget/.../reset.

reason is free-form audit metadata, capped like its question-attempt twin so it round-trips through the SSE frame.

reason: str = ''
class LlmBudgetRow(ctfy.core.models.CtfyModel):
797class LlmBudgetRow(CtfyModel):
798    """One team's LLM spend on one challenge.
799
800    ⚠️ ``budget_tokens`` is the cap **as configured right now**, not the
801    cap that applied when the spend happened: it is one deployment-wide
802    setting the operator can retune mid-event. So a row can read
803    ``spent 90k of 200k`` for a team that really was refused at 50k, and
804    the honest place to see what happened *then* is the
805    ``llm_quota_exhausted`` activity row, which carries the cap it
806    crossed. Reporting a per-row historical cap would mean storing one,
807    which is a column that exists only to be believed.
808
809    ``exhausted`` is derived from the pair rather than stored, for the
810    same reason: it is a statement about the two numbers on this row and
811    must not disagree with them.
812    """
813
814    team_id: str
815    team_name: str = ""
816    competition_id: str
817    challenge_id: str
818    spent_tokens: int
819    budget_tokens: int
820    #: Whether this team still holds a live grant for this challenge.
821    #: A budget outlives the box that spent it — the key is
822    #: ``(team, competition, challenge)`` precisely so a fresh instance
823    #: does not refill — so a row with no live grant is the normal
824    #: shape after a box stops, not an orphan.
825    live: bool = False
826
827    @computed_field
828    @property
829    def exhausted(self) -> bool:
830        """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified``
831        which is deliberately a plain ``@property``. Opposite reasons,
832        and the distinction is the model's kind: that one is *state*, so
833        ``model_dump`` feeds ``to_orm`` and every dumped key has to be a
834        column; this is a *wire* model whose whole purpose is to reach a
835        client, and a plain property simply never leaves the process.
836        """
837        return self.budget_tokens > 0 and self.spent_tokens >= self.budget_tokens

One team's LLM spend on one challenge.

⚠️ budget_tokens is the cap as configured right now, not the cap that applied when the spend happened: it is one deployment-wide setting the operator can retune mid-event. So a row can read spent 90k of 200k for a team that really was refused at 50k, and the honest place to see what happened then is the llm_quota_exhausted activity row, which carries the cap it crossed. Reporting a per-row historical cap would mean storing one, which is a column that exists only to be believed.

exhausted is derived from the pair rather than stored, for the same reason: it is a statement about the two numbers on this row and must not disagree with them.

team_id: str = PydanticUndefined
team_name: str = ''
competition_id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
spent_tokens: int = PydanticUndefined
budget_tokens: int = PydanticUndefined
live: bool = False
exhausted: bool
827    @computed_field
828    @property
829    def exhausted(self) -> bool:
830        """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified``
831        which is deliberately a plain ``@property``. Opposite reasons,
832        and the distinction is the model's kind: that one is *state*, so
833        ``model_dump`` feeds ``to_orm`` and every dumped key has to be a
834        column; this is a *wire* model whose whole purpose is to reach a
835        client, and a plain property simply never leaves the process.
836        """
837        return self.budget_tokens > 0 and self.spent_tokens >= self.budget_tokens

⚠️ A computed_field, unlike TeamState.is_disqualified which is deliberately a plain @property. Opposite reasons, and the distinction is the model's kind: that one is state, so model_dump feeds to_orm and every dumped key has to be a column; this is a wire model whose whole purpose is to reach a client, and a plain property simply never leaves the process.

class LlmCallBody(ctfy.core.models.CtfyModel):
1211class LlmCallBody(CtfyModel):
1212    """One call's archived conversation.
1213
1214    ⚠️ **Attacker-authored text.** Every byte of the prompt half is
1215    written by whoever is attacking the challenge. Rendered as plain
1216    text, never markdown, never HTML.
1217    """
1218
1219    id: str = ""
1220    #: The rewritten request the gateway actually sent, as JSON text.
1221    request: str = ""
1222    #: The completion, reassembled from the stream where it was one.
1223    response: str = ""
1224    truncated: bool = False
1225    #: ``False`` when retention has reclaimed the object; the row stays
1226    #: so the page can say the call happened.
1227    available: bool = True

One call's archived conversation.

⚠️ Attacker-authored text. Every byte of the prompt half is written by whoever is attacking the challenge. Rendered as plain text, never markdown, never HTML.

id: str = ''
request: str = ''
response: str = ''
truncated: bool = False
available: bool = True
class LlmCallPage(ctfy.core.models.CtfyModel):
798class LlmCallPage(CtfyModel):
799    """A page of calls, plus what the reader needs to trust it."""
800
801    items: list[LlmCallRow] = Field(default_factory=list)
802    total: int = 0
803    #: ⚠️ Distinguishes "no calls" from "this deployment keeps no index",
804    #: which look identical and call for opposite actions. Same argument
805    #: as ``LlmBudgetPage.gateway_configured``.
806    indexing: bool = True
807    #: When the built-in price list was true. A money figure with no date
808    #: is the one somebody trusts a year later.
809    rates_as_of: str = ""

A page of calls, plus what the reader needs to trust it.

items: list[LlmCallRow] = PydanticUndefined
total: int = 0
indexing: bool = True
rates_as_of: str = ''
class LlmCallRow(ctfy.core.models.CtfyModel):
698class LlmCallRow(CtfyModel):
699    """One relayed model call, as a page reads it.
700
701    Wire naming: ``id`` is this row's own id, everything ``<entity>_id``
702    is a foreign key. The frontend reads these verbatim.
703
704    ⚠️ ``usage_known`` rides along so the page can tell "0 tokens" from
705    "nobody knows" — a distinction ``extract_usage`` preserves on purpose
706    (the torn-stream rule charges rather than refunds) and which a table
707    rendering both as ``0`` would destroy at the last metre.
708
709    ⚠️ ``estimated_usd`` is ``None`` for a model no price list knows, and
710    the page must show a dash rather than ``$0.00``. It is a
711    ``computed_field`` because this is a *wire* model — see the
712    ``LlmBudgetRow.exhausted`` rule.
713    """
714
715    id: str = ""
716    at_ts: float = 0.0
717    # ⚠️ ``str``, not ``Principal`` — the wire projection of a column
718    # that can hold a value this platform does not model (see the note
719    # on ``LlmCallState.principal``). Narrowing here moves the failure
720    # from "the drawer shows an unfamiliar word" to "the admin page
721    # 500s", which is the worse of the two by a distance.
722    principal: str = "instance"
723    competition_id: str = ""
724    team_id: str = ""
725    challenge_id: str = ""
726    instance_id: str = ""
727    flavor: FlavorOrUnset = ""
728    model: str = ""
729    status: int = 0
730    stream: bool = False
731    aborted: bool = False
732    truncated: bool = False
733    stop_reason: str = ""
734    usage_known: bool = False
735    input_tokens: int = 0
736    output_tokens: int = 0
737    cache_write_tokens: int = 0
738    cache_read_tokens: int = 0
739    upstream_ms: int = 0
740    queue_ms: int = 0
741    attempts: int = 1
742    key_id: str = ""
743    request_bytes: int = 0
744    #: Whether the body is still in storage. The *key* itself never
745    #: reaches a player — it is an address in the operator's bucket.
746    has_body: bool = False
747    #: What the owning squad is called, beside the ``team_id`` above.
748    #: ``""`` on the player's own run page, where the owner would only
749    #: ever be themselves.
750    #:
751    #: ⚠️ **This was ``owner_label``, and it was fed ``row.team_id``
752    #: verbatim for its whole life** — a uuid in the field two admin
753    #: pages render as their Owner column, which drew
754    #: ``3f9a1b2c-…`` under a heading promising a name.
755    #: `MyEvalRunRow.owner_label`'s recorded defect on the neighbouring
756    #: model, one composer along.
757    #:
758    #: ⚠️ **``team_name``, not ``owner_display_name``, and the earlier
759    #: refusal got half of its own reason wrong.** It argued the suffix
760    #: "would enlist a squad's identity in a person-identity scan" —
761    #: measured, ``test_anonymity_doors.IDENTITY_FIELDS`` has carried
762    #: ``team_name`` and ``first_blood_team_name`` since it was written,
763    #: so squad identity was never outside that scan and being in it is
764    #: the point: it is what forces the door question to be answered.
765    #: What survives of the refusal is the *producer* half —
766    #: `entity_labels` rules on what a **person** is called and
767    #: `LlmCallState` carries no user id — so the label comes from
768    #: `ctfy.server.team_labels.operator_team_labels` and the field
769    #: takes the name `InstanceInfo` already uses for this exact fact.
770    #:
771    #: ⚠️ **Never the id as a fallback** (`InstanceInfo.team_name`'s
772    #: contract). ``""`` beside a set id is the third state, and here it
773    #: has two readings the id itself separates: a uuid means the squad
774    #: is gone or never named itself, while ``__platform__`` is
775    #: `PLATFORM_TEAM_ID`, the sentinel chosen to read as itself.
776    team_name: str = ""
777
778    @computed_field
779    @property
780    def estimated_usd(self) -> float | None:
781        """⚠️ ``None``, never ``0.0``, for an unpriced model."""
782        from ctfy.core.llm_pricing import estimate_usd
783        from ctfy.core.llm_spend import Spend
784
785        if not self.usage_known:
786            return None
787        return estimate_usd(
788            self.model,
789            Spend(
790                input_tokens=self.input_tokens,
791                output_tokens=self.output_tokens,
792                cache_write_tokens=self.cache_write_tokens,
793                cache_read_tokens=self.cache_read_tokens,
794            ),
795        )

One relayed model call, as a page reads it.

Wire naming: id is this row's own id, everything <entity>_id is a foreign key. The frontend reads these verbatim.

⚠️ usage_known rides along so the page can tell "0 tokens" from "nobody knows" — a distinction extract_usage preserves on purpose (the torn-stream rule charges rather than refunds) and which a table rendering both as 0 would destroy at the last metre.

⚠️ estimated_usd is None for a model no price list knows, and the page must show a dash rather than $0.00. It is a computed_field because this is a wire model — see the LlmBudgetRow.exhausted rule.

id: str = ''
at_ts: float = 0.0
principal: str = 'instance'
competition_id: str = ''
team_id: str = ''
challenge_id: str = ''
instance_id: str = ''
flavor: Union[ctfy.core.enums.Flavor, Literal['']] = ''
model: str = ''
status: int = 0
stream: bool = False
aborted: bool = False
truncated: bool = False
stop_reason: str = ''
usage_known: bool = False
input_tokens: int = 0
output_tokens: int = 0
cache_write_tokens: int = 0
cache_read_tokens: int = 0
upstream_ms: int = 0
queue_ms: int = 0
attempts: int = 1
key_id: str = ''
request_bytes: int = 0
has_body: bool = False
team_name: str = ''
estimated_usd: float | None
778    @computed_field
779    @property
780    def estimated_usd(self) -> float | None:
781        """⚠️ ``None``, never ``0.0``, for an unpriced model."""
782        from ctfy.core.llm_pricing import estimate_usd
783        from ctfy.core.llm_spend import Spend
784
785        if not self.usage_known:
786            return None
787        return estimate_usd(
788            self.model,
789            Spend(
790                input_tokens=self.input_tokens,
791                output_tokens=self.output_tokens,
792                cache_write_tokens=self.cache_write_tokens,
793                cache_read_tokens=self.cache_read_tokens,
794            ),
795        )

⚠️ None, never 0.0, for an unpriced model.

class LlmModelOption(ctfy.core.models.CtfyModel):
397class LlmModelOption(CtfyModel):
398    """A model an entrant may pick, as an entrant may see it.
399
400    ⚠️ **A projection, not :class:`LlmModelState`.** The registry row
401    carries the operator's own scratchpad, who reviewed it, and the
402    addressing that says where the deployment's credential is spent
403    (``base_url``, ``provider_model``). None of that is an entrant's
404    business — and a route returning the state model would publish every
405    one of those the first time somebody added a field to it.
406
407    ⚠️ ``flavor`` is load-bearing rather than descriptive: a
408    bring-your-own key is stored per flavor, so "do I already have a key
409    for this model" is a question the page can only answer if the model
410    says which flavor it is.
411    """
412
413    id: str = ""
414    display_name: str = ""
415    flavor: FlavorOrUnset = ""
416    vendor: str = ""
417    #: USD per 1,000,000 tokens. Carried so an entrant can weigh a
418    #: choice against the allowance they hold; ``0`` means unpriced, the
419    #: same reading the leaderboard gives it.
420    input_price_per_mtok: float = 0.0
421    output_price_per_mtok: float = 0.0

A model an entrant may pick, as an entrant may see it.

⚠️ A projection, not LlmModelState. The registry row carries the operator's own scratchpad, who reviewed it, and the addressing that says where the deployment's credential is spent (base_url, provider_model). None of that is an entrant's business — and a route returning the state model would publish every one of those the first time somebody added a field to it.

⚠️ flavor is load-bearing rather than descriptive: a bring-your-own key is stored per flavor, so "do I already have a key for this model" is a question the page can only answer if the model says which flavor it is.

id: str = ''
display_name: str = ''
flavor: Union[ctfy.core.enums.Flavor, Literal['']] = ''
vendor: str = ''
input_price_per_mtok: float = 0.0
output_price_per_mtok: float = 0.0
class LlmProviderChoice(ctfy.core.models.CtfyModel):
550class LlmProviderChoice(CtfyModel):
551    """One upstream a user may point their own key at.
552
553    ⚠️ **Served, not shipped in the client.** The operator can replace
554    the set, and a client with its own copy would offer providers the
555    server refuses — the user then does exactly what the page said and
556    is turned away.
557
558    ⚠️ **``flavor`` rides with the address** because a provider is both.
559    DeepSeek, Moonshot and OpenAI all speak ``openai``, so a form asking
560    the two separately contains a wrong combination, and the wrong
561    combination stores a key where nothing will look for it.
562    """
563
564    id: str
565    label: str
566    #: ⚠️ Required and never empty: an entry in this catalog *is* a
567    #: provider, so a dialect-less one would be an address the form
568    #: could offer and no credential could be stored against.
569    flavor: Flavor
570    base_url: str

One upstream a user may point their own key at.

⚠️ Served, not shipped in the client. The operator can replace the set, and a client with its own copy would offer providers the server refuses — the user then does exactly what the page said and is turned away.

⚠️ flavor rides with the address because a provider is both. DeepSeek, Moonshot and OpenAI all speak openai, so a form asking the two separately contains a wrong combination, and the wrong combination stores a key where nothing will look for it.

id: str = PydanticUndefined
label: str = PydanticUndefined
flavor: ctfy.core.enums.Flavor = PydanticUndefined
base_url: str = PydanticUndefined
class LlmProviderCreate(ctfy.core.models.CtfyModel):
774class LlmProviderCreate(CtfyModel):
775    """A provider as an operator types it.
776
777    ⚠️ Validated **here**, not only at the gateway. ``POST /keys/sync``
778    reports a ``rejected`` list whose whole meaning is "the platform and
779    the gateway have drifted, somebody look" — which is only true if a
780    row the gateway would refuse could not have been stored. Without
781    this, a refusal there would be the ordinary result of a typo and
782    nobody would read it.
783    """
784
785    key_id: str = Field(min_length=1, max_length=64)
786    api_key: str = Field(min_length=1, max_length=512)
787    #: ⚠️ **The type is the check.** This was a ``str`` with a
788    #: ``field_validator`` reaching into the gateway package for a tuple
789    #: of two literals — one of three hand-rolled copies of a set the
790    #: enum already closes. Declaring it here refuses the same typo at
791    #: the same boundary with the same 422, and puts the allowed values
792    #: in the OpenAPI schema, so the browser's own types carry them too.
793    flavor: Flavor
794    base_url: str = ""

A provider as an operator types it.

⚠️ Validated here, not only at the gateway. POST /keys/sync reports a rejected list whose whole meaning is "the platform and the gateway have drifted, somebody look" — which is only true if a row the gateway would refuse could not have been stored. Without this, a refusal there would be the ordinary result of a typo and nobody would read it.

key_id: str = PydanticUndefined
api_key: str = PydanticUndefined
flavor: ctfy.core.enums.Flavor = PydanticUndefined
base_url: str = ''
class LlmProviderInfo(ctfy.core.models.CtfyModel):
582class LlmProviderInfo(CtfyModel):
583    """One upstream credential as an operator may see it.
584
585    ⚠️ **No ``api_key`` field, and that absence is the model.** The
586    column is sealed precisely so the secret cannot be read back, and a
587    wire model carrying it would undo that for every caller at once. A
588    last-four ``hint`` is included because an operator rotating one of
589    several keys has to tell them apart, and four characters of a
590    100-bit secret identify without reconstructing — the alternative is
591    a table of opaque ids where picking the wrong row revokes the wrong
592    vendor.
593    """
594
595    key_id: str
596    flavor: FlavorOrUnset
597    base_url: str = ""
598    #: Last four characters of the secret, or empty for one too short to
599    #: hint at without giving away a meaningful share of it.
600    hint: str = ""
601    added_by: str = ""
602    added_at: str = ""
603    #: Whether the platform's last key push carried this row to the
604    #: gateway: ``synced`` / ``pending`` / ``rejected``, or ``""`` when
605    #: there is no gateway to push to.
606    #:
607    #: ⚠️ **A record of the last push, never a live reading**, and the
608    #: page's copy has to say so. The gateway persists nothing, so it can
609    #: restart and lose the whole fleet a second after acknowledging it;
610    #: the platform hears on the next tick. Deriving it any other way
611    #: would mean asking the gateway from a browser — a second source for
612    #: a fact only the pushing party knows the outcome of.
613    #:
614    #: ⚠️ Defaults to ``""``, the *not-a-new-claim* direction. Defaulting
615    #: to ``synced`` would make a producer that forgets assert every
616    #: credential is live; same rule as ``ScoreboardEntry.review_status``
617    #: and ``SubmissionResponse.ranked``.
618    sync_state: str = ""
619    #: Whether this secret is also an ``CTFY_EVAL_*`` key — one upstream
620    #: account serving both the players' gateway and the eval harness,
621    #: which spends **directly**. The gateway's ledger then calibrates
622    #: against capacity somebody else is consuming: it under-counts, the
623    #: limiter over-admits, and the first observable is a 429 storm or a
624    #: bill.
625    #:
626    #: ⚠️ Derived on the read, never stored — a column would need
627    #: clearing when either side is rotated, and a missed write reads
628    #: exactly like "these are different accounts". Defaults ``False``
629    #: for the usual reason: the default must not be a new claim.
630    shares_eval_account: bool = False
631    #: How many *user* keys sit at this row's ``(flavor, base_url)`` and
632    #: therefore spend instead of it.
633    #:
634    #: ⚠️ **Not a warning about the credential — a warning about the
635    #: page.** `execute_one_eval_run` reads
636    #: ``get_user_llm_key(owner, flavor, model.base_url)`` first and
637    #: never consults the fleet row when one answers, so for those runs
638    #: this row's green probe is about a credential nothing spent.
639    #: Measured: an operator read that tick while a run burned 77
640    #: minutes against an expired BYOK key at the same address, and
641    #: nothing on either page connected the two.
642    #:
643    #: ⚠️ A count, never the owners. Naming them would make this page
644    #: report who holds a credential with which provider — the same
645    #: disclosure `DELETE /eval/llm-keys/{flavor}`'s 404 exists to
646    #: avoid. The number is what changes the reading; the identities
647    #: are somebody else's business.
648    #:
649    #: ⚠️ Derived on the read, like ``shares_eval_account`` above and
650    #: for its reason: a stored count needs clearing whenever any user
651    #: adds or drops a key, and a missed write reads exactly like
652    #: "nothing shadows this".
653    shadowed_by: int = 0

One upstream credential as an operator may see it.

⚠️ No api_key field, and that absence is the model. The column is sealed precisely so the secret cannot be read back, and a wire model carrying it would undo that for every caller at once. A last-four hint is included because an operator rotating one of several keys has to tell them apart, and four characters of a 100-bit secret identify without reconstructing — the alternative is a table of opaque ids where picking the wrong row revokes the wrong vendor.

key_id: str = PydanticUndefined
flavor: Union[ctfy.core.enums.Flavor, Literal['']] = PydanticUndefined
base_url: str = ''
hint: str = ''
added_by: str = ''
added_at: str = ''
sync_state: str = ''
shares_eval_account: bool = False
shadowed_by: int = 0
class LlmProviderPage(ctfy.core.models.CtfyModel):
699class LlmProviderPage(CtfyModel):
700    items: list[LlmProviderInfo] = Field(default_factory=list)
701    total: int = 0
702    #: Whether this deployment has a gateway at all. ⚠️ An empty table
703    #: is ambiguous and must say which emptiness it is: "no providers
704    #: configured" and "this platform pushes to nothing" render
705    #: identically and call for opposite actions. Same argument as
706    #: ``webhook_configured`` on the email-suppression list.
707    gateway_configured: bool = False
708    #: Whether an upstream key can be sealed at rest here at all, i.e.
709    #: whether ``POST`` can succeed.
710    #:
711    #: ⚠️ **A precondition, and the browser could not see it.** The write
712    #: route pre-checks ``pii.is_enabled()`` and answers ``503
713    #: sealing_not_configured`` with a message naming the variable to
714    #: set — but nothing on the read said so, so the only way to find out
715    #: was to paste a *real* credential into a form that was always going
716    #: to refuse it. The wizard asks before it opens.
717    #:
718    #: ⚠️ Defaults ``False`` because a default must never be a new claim
719    #: (same rule as ``gateway_configured`` above and
720    #: ``SubmissionResponse.ranked``) — and the *client* reads
721    #: ``=== false`` rather than ``!``, so an older server, which sends
722    #: no field at all, does not have its add button taken away. Gates
723    #: fail open; only a server that positively says "I cannot seal"
724    #: closes this one.
725    sealing_configured: bool = False

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

items: list[LlmProviderInfo] = PydanticUndefined
total: int = 0
gateway_configured: bool = False
sealing_configured: bool = False
class LlmProviderProbe(ctfy.core.models.CtfyModel):
728class LlmProviderProbe(CtfyModel):
729    """What one probe of one credential established.
730
731    ⚠️ **A code, never a boolean.** Four refusals mean four different
732    fields to go and fix — the key, the address, the model, the account —
733    and one red cross reports them identically. The page renders the code
734    and shows ``detail`` beside it, which is the only thing separating
735    the two ambiguous causes of ``bad_request``.
736    """
737
738    key_id: str = ""
739    ok: bool = False
740    #: ``ok`` · ``auth_failed`` · ``quota_exceeded`` · ``unreachable`` ·
741    #: ``bad_request`` — the provider's verdict; plus ``no_gateway`` ·
742    #: ``gateway_unreachable`` · ``not_synced``, which are facts about
743    #: *this platform's* wiring rather than about the credential.
744    code: str = ""
745    #: The status the provider actually sent, kept verbatim: the code is
746    #: a summary and a bug report wants the original. 0 when nothing
747    #: answered.
748    status: int = 0
749    #: The provider's own message, bounded. Never our own paraphrase —
750    #: it is what names the wrong model or the wrong host.
751    detail: str = ""
752    #: What the model actually said, bounded by the gateway.
753    #:
754    #: ⚠️ **This is what makes the verdict about the model rather than
755    #: about the endpoint.** A 200 with an empty completion — a misrouted
756    #: gateway, a content filter, a proxy answering for a provider it
757    #: never reached — is indistinguishable from a working credential by
758    #: status code alone. Showing the answer is how an operator tells
759    #: those apart, and empty is therefore a *result* rather than a
760    #: missing field.
761    #:
762    #: ⚠️ **Reported, never graded.** A check on the text would fail on
763    #: correct configuration — a weak model, a stray "Sure!", a reply in
764    #: another language — and this repository's own record is that such a
765    #: check is the one that gets deleted.
766    reply: str = ""
767    #: ⚠️ The offline stub answers **ok**, so a green tick there would be
768    #: the dangerous result: an operator carries it into an event whose
769    #: every answer is invented. Defaults ``False`` for the usual reason
770    #: — the default must not be a new claim.
771    stub: bool = False

What one probe of one credential established.

⚠️ A code, never a boolean. Four refusals mean four different fields to go and fix — the key, the address, the model, the account — and one red cross reports them identically. The page renders the code and shows detail beside it, which is the only thing separating the two ambiguous causes of bad_request.

key_id: str = ''
ok: bool = False
code: str = ''
status: int = 0
detail: str = ''
reply: str = ''
stub: bool = False
class LoginRequest(ctfy.core.models.CtfyModel):
110class LoginRequest(CtfyModel):
111    email: EmailStr
112    password: str = Field(min_length=1, max_length=256)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

email: pydantic.networks.EmailStr = PydanticUndefined
password: str = PydanticUndefined
class MeResponse(ctfy.server.models.UserInfo):
 65class MeResponse(UserInfo):
 66    """``GET /me`` payload — the logged-in user's full profile.
 67
 68    Extends :class:`UserInfo` with auth-state context so the frontend
 69    can render Settings (linked providers, admin badge) with a single
 70    request. ``providers`` lists provider names currently bound
 71    (e.g. ``["github", "google"]``); ``token_kind`` distinguishes
 72    ``"user"`` (browser session) from ``"fine_grained"`` (CLI / agent
 73    token).
 74
 75    ``is_admin`` is the OR of admin + super_admin so existing clients
 76    keep working without inspecting ``role`` directly.
 77
 78    ``competition_teams`` carries every per-comp team the user is
 79    currently on. The legacy ``current_team_id`` /
 80    ``current_team_name`` fields were dropped — there is no global
 81    "current team" any more.
 82    """
 83
 84    email: str = ""
 85    is_admin: bool = False
 86    role: UserRole = "user"
 87    providers: list[str] = Field(default_factory=list)
 88    #: When this account was taken off public standings; ``None`` = it
 89    #: counts. On *this* projection because it is the account's own view
 90    #: of itself: being invisible to the field without being told is the
 91    #: kind of surprise that gets reported as a scoring bug. It is not on
 92    #: the public ``UserInfo`` — a player has no business learning which
 93    #: accounts belong to the deployment.
 94    standings_excluded_at: datetime | None = None
 95    token_kind: TokenKind = "user"
 96    #: Which of this account's addresses the badge derives from; ``""``
 97    #: is no badge. On *this* projection and never on
 98    #: :class:`UserInfo`, for `standings_excluded_at`'s reason inverted:
 99    #: the badge itself is public and the address behind it is not, so
100    #: the one reader entitled to it is the account choosing.
101    #:
102    #: ⚠️ It can name an address that earns **no** badge —  unverified,
103    #: or at a domain the operator has not named — and the settings card
104    #: needs exactly that pair to say *"chosen, and nothing is being
105    #: shown"*. Reading ``affiliation`` alone cannot tell that from
106    #: *"nothing chosen"*, which are opposite repairs.
107    affiliation_email: str = ""
108    profile_visibility: dict[str, bool] = Field(default_factory=dict)
109    competition_teams: list[CompetitionMembershipInfo] = Field(default_factory=list)
110    # Competitions this user per-comp-administers. Empty for global
111    # admins/super_admins (already a superset — the frontend treats
112    # ``is_admin`` as "admin of every competition").
113    competition_admin_ids: list[str] = Field(default_factory=list)
114    # Competitions whose registration roster this user may read + rule on.
115    # A superset of ``competition_admin_ids`` (an admin can review), plus
116    # any narrow ``reviewer`` grant. Drives whether the frontend shows the
117    # registrations link; the server re-checks on every request.
118    competition_reviewer_ids: list[str] = Field(default_factory=list)
119    # Competitions whose uploaded challenges this user may read and rule
120    # on (plan P2.3). A superset of ``competition_admin_ids``, plus any
121    # narrow authoring-reviewer grant — the same shape as the list above,
122    # and deliberately **not** a subset of it: this grant reaches no
123    # entrant's registration details. Drives whether the frontend shows
124    # the upload queue; the server re-checks on every request.
125    competition_authoring_reviewer_ids: list[str] = Field(default_factory=list)
126    # True when this request asked to be served as an ordinary player
127    # (``X-Ctfy-View-As: player``). Transient — it describes the
128    # *request*, never the stored account, so ``role`` above already
129    # reads ``user`` while the DB row still says admin. The client
130    # needs it to render the "you are previewing" banner and the way
131    # back out; without it a narrowed session is indistinguishable
132    # from an ordinary one and nobody can leave.
133    viewing_as_player: bool = False
134    # What this account said it came here to do, asked once at sign-up.
135    # ⚠️ ``""`` is "we never asked", never a preference — the welcome
136    # wizard shows *because* of it, and an older server that omits the
137    # field lands on the same default, which is the honest reading.
138    onboarding_intent: OnboardingIntent = ""
139    # Whether this account's address has ever been proved — by an OAuth
140    # provider, an emailed sign-in code, or the verification exchange.
141    #
142    # ⚠️ Defaults **False**, the honest reading: the platform holds no
143    # proof until something writes one, so the withheld value asserts
144    # nothing new. The client must not turn that into a nag on its own —
145    # it reads ``=== false`` **and** the flag below, so an older server
146    # (which sends neither) shows nothing.
147    email_verified: bool = False
148    # Whether this deployment refuses writes from an unproved address.
149    # Composed server-side from the platform setting *and* whether mail
150    # can be sent at all: enforcement without a mail provider is an
151    # outage, not a policy, so the server resolves it rather than letting
152    # the browser ``&&`` two facts and reach a different answer than the
153    # gate did. Defaults **False** — a client that cannot tell must never
154    # invent a requirement.
155    email_verification_required: bool = False
156    # Which factor this account re-proves before ``DELETE /me`` runs —
157    # see :mod:`ctfy.server.account_deletion`. Composed server-side for
158    # the reason the field above is: the gate's own predicate, so the
159    # Danger Zone cannot render a control the route will refuse, or skip
160    # one it demands. Defaults ``none``, the behaviour that shipped
161    # before the gate existed.
162    deletion_proof: AccountDeletionProof = "none"
163    # When this account's challenge uploads reopen, while a pause after
164    # repeated rejections runs (plan P2.7); ``None`` when nothing is
165    # paused. Composed server-side from the upload route's own function
166    # (``ctfy.server.authoring_cooldown.paused_until``), so the authoring
167    # page cannot draw a form the route refuses. ⚠️ Defaults ``None`` —
168    # an older server that omits it must never read as a pause.
169    authoring_paused_until: datetime | None = None

GET /me payload — the logged-in user's full profile.

Extends UserInfo with auth-state context so the frontend can render Settings (linked providers, admin badge) with a single request. providers lists provider names currently bound (e.g. ["github", "google"]); token_kind distinguishes "user" (browser session) from "fine_grained" (CLI / agent token).

is_admin is the OR of admin + super_admin so existing clients keep working without inspecting role directly.

competition_teams carries every per-comp team the user is currently on. The legacy current_team_id / current_team_name fields were dropped — there is no global "current team" any more.

email: str = ''
is_admin: bool = False
role: Literal['user', 'admin', 'super_admin'] = 'user'
providers: list[str] = PydanticUndefined
standings_excluded_at: datetime.datetime | None = None
token_kind: Literal['user', 'agent', 'fine_grained'] = 'user'
affiliation_email: str = ''
profile_visibility: dict[str, bool] = PydanticUndefined
competition_teams: list[CompetitionMembershipInfo] = PydanticUndefined
competition_admin_ids: list[str] = PydanticUndefined
competition_reviewer_ids: list[str] = PydanticUndefined
competition_authoring_reviewer_ids: list[str] = PydanticUndefined
viewing_as_player: bool = False
onboarding_intent: Literal['', 'human', 'connect', 'byo', 'author', 'skipped'] = ''
email_verified: bool = False
email_verification_required: bool = False
deletion_proof: Literal['password', 'email_code', 'none'] = 'none'
authoring_paused_until: datetime.datetime | None = None
class MetaAgents(ctfy.core.models.CtfyModel):
 82class MetaAgents(CtfyModel):
 83    """How much of this deployment's population is machine, not human.
 84
 85    Admin-only, like the cluster counters it sits beside — an entrant
 86    count is competitive telemetry.
 87
 88    ⚠️ **Three numbers, because they are three different questions and
 89    only the first is "how many AI agents did users register".** A
 90    harness is the registered image; an *entrant* is one synthetic user
 91    per (owner, harness, model), so three agents across four models is
 92    twelve entrants; a *run* is one dispatch. Collapsing them into a
 93    single "agents" figure picks one meaning and makes the other two
 94    unreadable — and the one a reader assumes is the one this field set
 95    exists to name.
 96    """
 97
 98    #: Harnesses with a real owner — the answer to "how many agents did
 99    #: our users register". ⚠️ Deliberately excludes the platform's own
100    #: rows, which carry ``owner_user_id == ""``; that empty string is a
101    #: value on the column (it is what ``is_dispatchable`` reads as
102    #: "built here, no review needed"), not an absent owner.
103    registered: int = 0
104    #: Of those, still awaiting review. The actionable one: an
105    #: unreviewed harness cannot be dispatched, so a number sitting here
106    #: is somebody blocked on an operator.
107    pending_review: int = 0
108    #: The platform's own harnesses — the complement of ``registered``,
109    #: carried so a reader can tell "nobody has registered one yet" from
110    #: "the registry is empty".
111    platform: int = 0
112    #: Synthetic agent users (``UserState.entrant_kind == "agent"``).
113    entrants: int = 0
114    #: Active registered models an agent can be pointed at.
115    models: int = 0
116    #: Evaluation runs in flight — the agent twin of
117    #: ``running_instances``.
118    runs_running: int = 0

How much of this deployment's population is machine, not human.

Admin-only, like the cluster counters it sits beside — an entrant count is competitive telemetry.

⚠️ Three numbers, because they are three different questions and only the first is "how many AI agents did users register". A harness is the registered image; an entrant is one synthetic user per (owner, harness, model), so three agents across four models is twelve entrants; a run is one dispatch. Collapsing them into a single "agents" figure picks one meaning and makes the other two unreadable — and the one a reader assumes is the one this field set exists to name.

registered: int = 0
pending_review: int = 0
platform: int = 0
entrants: int = 0
models: int = 0
runs_running: int = 0
class MetaAuthoredArchive(ctfy.core.models.CtfyModel):
280class MetaAuthoredArchive(CtfyModel):
281    """What an uploaded challenge archive may be, in bytes.
282
283    ⚠️ **Published so the upload form can say it, and it could not.**
284    An archive over the limit came back as *too large to upload* with no
285    number anywhere on the page — the server's own message carried one,
286    and the page shows the error code's copy instead — so an author who
287    was refused learned nothing they could act on. The form reads these
288    to state the limits beside the file picker and to refuse an
289    oversized file before sending it.
290
291    ⚠️ **Zero is "this server did not say"**, which is what an older
292    platform sends: a client then states no limit and refuses nothing
293    itself, leaving the verdict to the server — never a guessed number
294    that could refuse an upload the server would take.
295    """
296
297    #: ``max_authored_challenge_bytes`` — the archive as sent, compressed.
298    archive_bytes: int = 0
299    #: ``MAX_TOTAL_BYTES`` — every file in it, unpacked.
300    unpacked_bytes: int = 0
301    #: ``MAX_FILE_BYTES`` — any one file, unpacked.
302    file_bytes: int = 0

What an uploaded challenge archive may be, in bytes.

⚠️ Published so the upload form can say it, and it could not. An archive over the limit came back as too large to upload with no number anywhere on the page — the server's own message carried one, and the page shows the error code's copy instead — so an author who was refused learned nothing they could act on. The form reads these to state the limits beside the file picker and to refuse an oversized file before sending it.

⚠️ Zero is "this server did not say", which is what an older platform sends: a client then states no limit and refuses nothing itself, leaving the verdict to the server — never a guessed number that could refuse an upload the server would take.

archive_bytes: int = 0
unpacked_bytes: int = 0
file_bytes: int = 0
class MetaChallenges(ctfy.core.models.CtfyModel):
58class MetaChallenges(CtfyModel):
59    """Source-control identity of the challenges repository, plus the
60    public count of available challenges.
61
62    ``commit_sha`` is None when the challenges directory isn't a git
63    working tree (e.g. ``CTFY_CHALLENGES_REPO=""`` or a bare checkout).
64    ``total`` is visible to everyone — the list itself is browsable.
65
66    ``suites`` / ``environments`` are the other two axes of "how big is
67    the catalog" (see :class:`~ctfy.core.challenge.CatalogCounts`):
68    distinct source buckets, and the subset that ships a runnable
69    Docker topology rather than being a pure QA item. Both are derived
70    from the same public spec list, so they carry no more information
71    than ``GET /challenges`` already does.
72    """
73
74    commit_sha: str | None = None
75    commit_url: str | None = None
76    repo_url: str = ""
77    total: int = 0
78    suites: int = 0
79    environments: int = 0

Source-control identity of the challenges repository, plus the public count of available challenges.

commit_sha is None when the challenges directory isn't a git working tree (e.g. CTFY_CHALLENGES_REPO="" or a bare checkout). total is visible to everyone — the list itself is browsable.

suites / environments are the other two axes of "how big is the catalog" (see ~ctfy.core.challenge.CatalogCounts): distinct source buckets, and the subset that ships a runnable Docker topology rather than being a pure QA item. Both are derived from the same public spec list, so they carry no more information than GET /challenges already does.

commit_sha: str | None = None
commit_url: str | None = None
repo_url: str = ''
total: int = 0
suites: int = 0
environments: int = 0
class MetaContribute(ctfy.core.models.CtfyModel):
254class MetaContribute(CtfyModel):
255    """Where the Contribute entry leads, as *this caller* may be shown it.
256
257    Composed by ``ctfy.server.contribute.contribute_for``: the operator's
258    ``contribute_competition_id``, kept only while that competition takes
259    challenges and only for a caller who may see it.
260
261    ⚠️ **``MetaResponse.contribute`` being ``None`` means "no entry",
262    and an older server that sends no field means the same.** A client
263    hides the sidebar item, the home card and the page's destination on
264    it — an entry that leads to a 404, or to "authoring has closed", is
265    worse than no entry.
266
267    ⚠️ **Per caller, never shared**, because visibility is: publishing
268    the id of a ``private_hidden`` competition to everybody is the
269    existence probe ``resolve_visible_competition`` answers 404 to
270    prevent.
271    """
272
273    #: The competition the entry leads to.
274    competition_id: str
275    #: Its title, so the entry can say where it leads without a second
276    #: request.
277    title: str = ""

Where the Contribute entry leads, as this caller may be shown it.

Composed by ctfy.server.contribute.contribute_for: the operator's contribute_competition_id, kept only while that competition takes challenges and only for a caller who may see it.

⚠️ MetaResponse.contribute being None means "no entry", and an older server that sends no field means the same. A client hides the sidebar item, the home card and the page's destination on it — an entry that leads to a 404, or to "authoring has closed", is worse than no entry.

⚠️ Per caller, never shared, because visibility is: publishing the id of a private_hidden competition to everybody is the existence probe resolve_visible_competition answers 404 to prevent.

competition_id: str = PydanticUndefined
title: str = ''
class MetaEvents(ctfy.core.models.CtfyModel):
176class MetaEvents(CtfyModel):
177    """How many competitions are open, as the *caller* may see them.
178
179    ⚠️ **Public, and that is a decision about ``access`` as well as
180    ``status``.** A published competition can still be
181    ``private_hidden``, which a non-invited user must not be able to
182    learn exists — the same probe ``competition_visible_to`` refuses by
183    answering 404 rather than 403. So this counts published rows that
184    are not hidden, which is exactly what the browsable catalog already
185    lists: no new disclosure, and a player finally gets the one number
186    that tells them there is something to play right now.
187
188    ⚠️ It under-reports for an invited user (their hidden event is not
189    in it). That is the safe direction and the catalog page shows them
190    the event anyway; the alternative is a per-caller invite join on a
191    chip.
192    """
193
194    live: int = 0
195    upcoming: int = 0

How many competitions are open, as the caller may see them.

⚠️ Public, and that is a decision about access as well as status. A published competition can still be private_hidden, which a non-invited user must not be able to learn exists — the same probe competition_visible_to refuses by answering 404 rather than 403. So this counts published rows that are not hidden, which is exactly what the browsable catalog already lists: no new disclosure, and a player finally gets the one number that tells them there is something to play right now.

⚠️ It under-reports for an invited user (their hidden event is not in it). That is the safe direction and the catalog page shows them the event anyway; the alternative is a per-caller invite join on a chip.

live: int = 0
upcoming: int = 0
class MetaInstanceTtl(ctfy.core.models.CtfyModel):
220class MetaInstanceTtl(CtfyModel):
221    """How long an instance may run, and what a client may offer for it.
222
223    ⚠️ **``options_s`` is composed by the server, and that is the whole
224    reason this group exists.** ``resolve_instance_ttl`` clamps a
225    caller's duration to ``max_instance_ttl_s`` *silently*, so a picker
226    that narrowed its own list would eventually offer a value the server
227    lowers without saying so — a control that lies about what pressing
228    it does. Nothing selectable from this list is clamped.
229
230    ⚠️ **Public, deliberately.** These are lifetimes, not capacity: a
231    player learns the default the instant their first countdown starts,
232    and a guest deciding whether to sign up is entitled to know a box
233    lives three hours rather than three minutes.
234
235    ⚠️ **An empty ``options_s`` is "this server did not say"**, which is
236    what an older platform sends, so a client reads it as *offer no
237    picker* rather than *offer nothing*. The single-press renewal still
238    works there: omitting ``ttl`` is what delegates to the platform.
239    """
240
241    #: ``default_instance_ttl_s`` — what a caller who names no duration
242    #: gets, and the option a picker should land the keyboard on.
243    default_s: int = 0
244    #: ``max_instance_ttl_s``. On a **renewal** this bounds the
245    #: resulting deadline rather than the increment, so it is also the
246    #: most time a box can ever have ahead of it — see
247    #: :func:`ctfy.core.instance_ttl.plan_renewal`.
248    max_s: int = 0
249    #: Ascending, every entry in ``[1, max_s]``, always carrying both
250    #: ``default_s`` and ``max_s``.
251    options_s: list[int] = Field(default_factory=list)

How long an instance may run, and what a client may offer for it.

⚠️ options_s is composed by the server, and that is the whole reason this group exists. resolve_instance_ttl clamps a caller's duration to max_instance_ttl_s silently, so a picker that narrowed its own list would eventually offer a value the server lowers without saying so — a control that lies about what pressing it does. Nothing selectable from this list is clamped.

⚠️ Public, deliberately. These are lifetimes, not capacity: a player learns the default the instant their first countdown starts, and a guest deciding whether to sign up is entitled to know a box lives three hours rather than three minutes.

⚠️ An empty options_s is "this server did not say", which is what an older platform sends, so a client reads it as offer no picker rather than offer nothing. The single-press renewal still works there: omitting ttl is what delegates to the platform.

default_s: int = 0
max_s: int = 0
options_s: list[int] = PydanticUndefined
class MetaLlm(ctfy.core.models.CtfyModel):
156class MetaLlm(CtfyModel):
157    """What the LLM gateway relayed today. Admin-only.
158
159    ⚠️ Counted from ``llm_calls`` — calls the platform itself relayed —
160    never from what an agent reported about itself. A run whose harness
161    left its rollup unset is invisible to the latter and fully counted
162    here.
163
164    ⚠️ Tokens, not dollars. Pricing a window means joining every row to
165    a model rate, and an unpriced model costs ``None`` rather than zero,
166    so a single figure here would silently under-report exactly the
167    models nobody has priced yet. The admin LLM pages own that sum.
168    """
169
170    #: Since 00:00 UTC today.
171    calls_today: int = 0
172    input_tokens_today: int = 0
173    output_tokens_today: int = 0

What the LLM gateway relayed today. Admin-only.

⚠️ Counted from llm_calls — calls the platform itself relayed — never from what an agent reported about itself. A run whose harness left its rollup unset is invisible to the latter and fully counted here.

⚠️ Tokens, not dollars. Pricing a window means joining every row to a model rate, and an unpriced model costs None rather than zero, so a single figure here would silently under-report exactly the models nobody has priced yet. The admin LLM pages own that sum.

calls_today: int = 0
input_tokens_today: int = 0
output_tokens_today: int = 0
class MetaMine(ctfy.core.models.CtfyModel):
198class MetaMine(CtfyModel):
199    """What *this caller* has, as opposed to what the platform has.
200
201    Present for any signed-in user, admin or not — a status bar that
202    only ever spoke to operators left a player with a registered agent
203    no way to see it was still waiting for review.
204
205    ⚠️ **Only what nothing else already answers.** Solves, attempts and
206    the caller's running instances reach the bar through ``/me`` and
207    ``InstanceCountContext``; repeating them here would give one number
208    two sources, which is the shape that drifts.
209    """
210
211    #: Harnesses this caller owns.
212    agents: int = 0
213    #: Of those, still awaiting an operator's review — the one number on
214    #: the bar that means *you* are blocked on somebody.
215    agents_pending: int = 0
216    #: This caller's own evaluation runs in flight.
217    runs_running: int = 0

What this caller has, as opposed to what the platform has.

Present for any signed-in user, admin or not — a status bar that only ever spoke to operators left a player with a registered agent no way to see it was still waiting for review.

⚠️ Only what nothing else already answers. Solves, attempts and the caller's running instances reach the bar through /me and InstanceCountContext; repeating them here would give one number two sources, which is the shape that drifts.

agents: int = 0
agents_pending: int = 0
runs_running: int = 0
class MetaOps(ctfy.core.models.CtfyModel):
121class MetaOps(CtfyModel):
122    """Operator-health counters: is anything wrong right now?
123
124    Admin-only. Every field here is a number an operator would want to
125    be non-zero-alerted on, which is why they sit together rather than
126    among the population counts.
127    """
128
129    #: Competitions a player can be inside *right now* — published and
130    #: within a session window. ⚠️ Both halves, not just the clock: a
131    #: draft competition inside its own window is "running" by the dates
132    #: and invisible to every player, so a phase-only count reports
133    #: events nobody can enter as live ones.
134    #:
135    #: ⚠️ **Not the same number as ``MetaEvents.live``**, and the pair is
136    #: kept rather than reconciled: this one includes ``private_hidden``
137    #: events, which the public count must not. An operator asking "is
138    #: anything live" wants the true answer including the invite-only
139    #: ones; showing them the public number instead would under-report
140    #: in the one direction that hides an event from the person
141    #: responsible for it. The panel shows both rows rather than a
142    #: difference, so neither figure has to be derived by a reader.
143    competitions_live: int = 0
144    competitions_upcoming: int = 0
145    #: Background tasks. ⚠️ ``failed`` is lifetime, not a window — the
146    #: chip is a pointer at the task list, and a failure that scrolled
147    #: out of a 24h window is still a failure nobody looked at.
148    tasks_running: int = 0
149    tasks_failed: int = 0
150    #: Notification outbox. A backed-up queue is otherwise invisible
151    #: until somebody reports never getting their mail.
152    outbox_pending: int = 0
153    outbox_failed: int = 0

Operator-health counters: is anything wrong right now?

Admin-only. Every field here is a number an operator would want to be non-zero-alerted on, which is why they sit together rather than among the population counts.

competitions_live: int = 0
competitions_upcoming: int = 0
tasks_running: int = 0
tasks_failed: int = 0
outbox_pending: int = 0
outbox_failed: int = 0
class MetaPlatform(ctfy.core.models.CtfyModel):
44class MetaPlatform(CtfyModel):
45    """Source-control identity of the running ctfy build.
46
47    ``commit_sha`` is None when the build couldn't determine its own
48    revision (no ``CTFY_GIT_COMMIT``, no usable git binary).
49    ``commit_url`` is built from ``repo_url`` + sha so the frontend
50    doesn't have to know the URL convention.
51    """
52
53    commit_sha: str | None = None
54    commit_url: str | None = None
55    repo_url: str = ""

Source-control identity of the running ctfy build.

commit_sha is None when the build couldn't determine its own revision (no CTFY_GIT_COMMIT, no usable git binary). commit_url is built from repo_url + sha so the frontend doesn't have to know the URL convention.

commit_sha: str | None = None
commit_url: str | None = None
repo_url: str = ''
class MetaResponse(ctfy.core.models.CtfyModel):
305class MetaResponse(CtfyModel):
306    version: str = ""
307    platform: MetaPlatform = Field(default_factory=MetaPlatform)
308    challenges: MetaChallenges = Field(default_factory=MetaChallenges)
309    started_at_ts: float = 0.0
310    server_time_ts: float = 0.0
311    # Cluster-wide counters — admin-only. ``None`` for non-admin
312    # callers so the frontend can distinguish "not authorised" from
313    # "happens to be zero" and hide the segment entirely instead of
314    # rendering a misleading "0 nodes".
315    teams_total: int | None = None
316    users_total: int | None = None
317    nodes_total: int | None = None
318    nodes_healthy: int | None = None
319    running_instances: int | None = None
320    solves_total: int | None = None
321    # The three admin-only groups above. ``None`` rather than a
322    # default-constructed object for the same reason every scalar
323    # beside them is: the frontend must be able to tell "not authorised"
324    # from "happens to be zero", and a zeroed group renders as a
325    # deployment with no agents, no live events and an idle gateway —
326    # a claim, where ``None`` is silence.
327    agents: MetaAgents | None = None
328    ops: MetaOps | None = None
329    llm: MetaLlm | None = None
330    # Public — every caller, guests included. See :class:`MetaEvents`
331    # for why a count of open competitions discloses nothing the
332    # catalog does not.
333    events: MetaEvents = Field(default_factory=MetaEvents)
334    # Public, like ``events`` above and for the same reason: a player
335    # meets these numbers the moment they launch anything, and the
336    # picker that reads them cannot be built without the ceiling.
337    instance_ttl: MetaInstanceTtl = Field(default_factory=MetaInstanceTtl)
338    # Per-caller. ``None`` for a signed-out visitor, who has no agents
339    # rather than zero of them — the same distinction the admin groups
340    # above draw, one identity down.
341    mine: MetaMine | None = None
342    # Per-caller, guests included: where the Contribute entry leads, or
343    # ``None`` for no entry. See :class:`MetaContribute`.
344    contribute: MetaContribute | None = None
345    # Public: a limit is a fact about the deployment, and an author
346    # deciding what to pack is entitled to it before signing in.
347    authored_archive: MetaAuthoredArchive = Field(default_factory=MetaAuthoredArchive)
348    # Public surface bit for the super-admin docker-exec feature. The
349    # frontend uses this to decide whether to render the Shell entry
350    # in the admin instance list — false means the routes aren't
351    # mounted, so any UI link would 404. Never carries the
352    # token/recording details — those stay server-side.
353    admin_shell_enabled: bool = False
354    # Where a defender's own `ssh` should knock, and — by being empty —
355    # whether the channel exists at all. ``POST /instances/{id}/ssh``
356    # refuses with 503 unless the CA key *and* the bastion host are both
357    # configured, so these two report the same pair rather than the raw
358    # config values: advertising a host with no CA would put a command
359    # on the defence card that always 503s, which a player reads as
360    # their box being broken. Public deliberately, unlike the counters
361    # below — it is the address the player types, and knowing where to
362    # knock buys nothing without a certificate the platform mints per
363    # instance. ``ssh_bastion_port`` is 0 (not its 2222 config default)
364    # when the channel is off, so neither field can imply a bastion the
365    # other denies.
366    ssh_bastion_host: str = ""
367    ssh_bastion_port: int = 0
368    # Operator-set deployment label (``CTFY_ENVIRONMENT_LABEL``), shown
369    # as a badge next to the brand so a staging tab is visually distinct
370    # from production. Empty = no badge. Public on purpose: it names the
371    # deployment, not anything about its internals.
372    environment_label: str = ""
373    # Canonical tier derived from the label (``ctfy.core.environment``).
374    # The client keys the frame / favicon tone and the prod-only confirm
375    # friction off this instead of substring-matching the free-text label
376    # itself, so classification lives in one place. ``prod`` is the
377    # fail-safe default (the unmarked deployment is production).
378    environment: Environment = "prod"

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

version: str = ''
platform: MetaPlatform = PydanticUndefined
challenges: MetaChallenges = PydanticUndefined
started_at_ts: float = 0.0
server_time_ts: float = 0.0
teams_total: int | None = None
users_total: int | None = None
nodes_total: int | None = None
nodes_healthy: int | None = None
running_instances: int | None = None
solves_total: int | None = None
agents: MetaAgents | None = None
ops: MetaOps | None = None
llm: MetaLlm | None = None
events: MetaEvents = PydanticUndefined
instance_ttl: MetaInstanceTtl = PydanticUndefined
mine: MetaMine | None = None
contribute: MetaContribute | None = None
authored_archive: MetaAuthoredArchive = PydanticUndefined
admin_shell_enabled: bool = False
ssh_bastion_host: str = ''
ssh_bastion_port: int = 0
environment_label: str = ''
environment: Literal['dev', 'staging', 'prod'] = 'prod'
class MilestoneProgress(ctfy.core.models.CtfyModel):
1034class MilestoneProgress(CtfyModel):
1035    """Per-challenge milestone progress for the calling user.
1036
1037    Returned by ``GET /me/milestone-progress``. One row per challenge
1038    with at least one captured question, regardless of whether the
1039    challenge is fully solved. The Challenges list page renders a
1040    progress bar from ``len(solved_question_ids) / total_questions``
1041    so a player can see "2/5 milestones" instead of an all-or-nothing
1042    solved badge.
1043
1044    Like ``MySolveSummary`` this is user-scoped: ``solved_question_ids``
1045    aggregates every question id the user captured for the challenge
1046    across every team they have ever been on. The optional
1047    ``competition_id`` query param narrows the aggregation to solves
1048    stamped against the user's team in that comp.
1049    """
1050
1051    challenge_id: str
1052    solved_question_ids: list[str] = Field(default_factory=list)
1053    total_questions: int = 0

Per-challenge milestone progress for the calling user.

Returned by GET /me/milestone-progress. One row per challenge with at least one captured question, regardless of whether the challenge is fully solved. The Challenges list page renders a progress bar from len(solved_question_ids) / total_questions so a player can see "2/5 milestones" instead of an all-or-nothing solved badge.

Like MySolveSummary this is user-scoped: solved_question_ids aggregates every question id the user captured for the challenge across every team they have ever been on. The optional competition_id query param narrows the aggregation to solves stamped against the user's team in that comp.

challenge_id: str = PydanticUndefined
solved_question_ids: list[str] = PydanticUndefined
total_questions: int = 0
class ModelStanding(ctfy.core.models.CtfyModel):
 57class ModelStanding(CtfyModel):
 58    """One model's overall standing plus its per-dimension breakdowns."""
 59
 60    rank: int = 0
 61    model_id: str = ""
 62    display_name: str = ""
 63    #: SDK wire protocol the platform dials this model with — an
 64    #: *addressing* detail with no bearing on the score.
 65    #:
 66    #: ⚠️ **Not the vendor**, though both its values are vendor names: a
 67    #: DeepSeek model served over the anthropic dialect carries
 68    #: ``flavor="anthropic"``. Anything rendering this unlabelled beside a
 69    #: model name is publishing a false attribution.
 70    flavor: FlavorOrUnset = ""
 71    #: owning vendor / org slug (blank when unattributed).
 72    vendor: str = ""
 73    #: the vendor's curated display name, falling back to the slug.
 74    #:
 75    #: ⚠️ Blank **only** when ``vendor`` is blank, so a reader can tell
 76    #: "unattributed" from "attributed to a vendor with no curated row" —
 77    #: the second is a label, the first is silence.
 78    vendor_display_name: str = ""
 79    #: provider model id actually requested (e.g. ``deepseek-chat``).
 80    provider_model: str = ""
 81    #: the harnesses whose runs this row pools, busiest first.
 82    harnesses: list[ModelHarnessRef] = Field(default_factory=list)
 83    runs: int = 0
 84    challenges_attempted: int = 0
 85    #: Challenges the platform never presented to this model at all — a
 86    #: range that would not start, a node that answered 5xx, a fleet with
 87    #: no capacity. ⚠️ Published beside ``challenges_attempted`` because
 88    #: ``solve_rate`` alone cannot express it: the rate has always
 89    #: divided by challenges that produced a run, so it is honest and
 90    #: says nothing about how much of the set it covers. Measured: a 50%
 91    #: over 26 challenges ranked beside a 66% over 102.
 92    challenges_errored: int = 0
 93    challenges_solved: int = 0
 94    solve_rate: float = 0.0
 95    questions_solved: int = 0
 96    input_tokens: int = 0
 97    output_tokens: int = 0
 98    #: input + output across all the model's succeeded runs.
 99    total_tokens: int = 0
100    #: cost-efficiency: total tokens per solved challenge (0 if none solved).
101    tokens_per_solve: float = 0.0
102    #: estimated USD cost (tokens × the model's price); 0 when unpriced.
103    cost_usd: float = 0.0
104    #: USD per solved challenge (0 when unpriced or nothing solved).
105    cost_per_solve: float = 0.0
106    avg_turns: float = 0.0
107    #: deepest pass@k sampling — max succeeded runs against any one challenge.
108    max_samples: int = 0
109    #: corpus version these runs were graded against (the model's modal sha).
110    corpus_sha: str = ""
111    #: dimension name (``difficulty`` / ``bucket`` / ``tag``) → its cells.
112    breakdowns: dict[str, list[LeaderboardCell]] = Field(default_factory=dict)

One model's overall standing plus its per-dimension breakdowns.

rank: int = 0
model_id: str = ''
display_name: str = ''
flavor: Union[ctfy.core.enums.Flavor, Literal['']] = ''
vendor: str = ''
vendor_display_name: str = ''
provider_model: str = ''
harnesses: list[ctfy.server.models.eval.ModelHarnessRef] = PydanticUndefined
runs: int = 0
challenges_attempted: int = 0
challenges_errored: int = 0
challenges_solved: int = 0
solve_rate: float = 0.0
questions_solved: int = 0
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
tokens_per_solve: float = 0.0
cost_usd: float = 0.0
cost_per_solve: float = 0.0
avg_turns: float = 0.0
max_samples: int = 0
corpus_sha: str = ''
breakdowns: dict[str, list[LeaderboardCell]] = PydanticUndefined
class MyAchievementsResponse(ctfy.core.models.CtfyModel):
107class MyAchievementsResponse(CtfyModel):
108    """The self-view: unlocked + locked (with secret-hiding)."""
109
110    unlocked: list[TeamAchievement] = Field(default_factory=list)
111    locked: list[AchievementCatalogEntry] = Field(default_factory=list)
112    summary: AchievementSummary = Field(default_factory=AchievementSummary)

The self-view: unlocked + locked (with secret-hiding).

unlocked: list[TeamAchievement] = PydanticUndefined
locked: list[AchievementCatalogEntry] = PydanticUndefined
summary: AchievementSummary = PydanticUndefined
class MyEvalRunPage(ctfy.core.models.CtfyModel):
1203class MyEvalRunPage(CtfyModel):
1204    """A page of runs, plus the same two envelope facts as the calls."""
1205
1206    items: list[MyEvalRunRow] = Field(default_factory=list)
1207    total: int = 0
1208    indexing: bool = True

A page of runs, plus the same two envelope facts as the calls.

items: list[MyEvalRunRow] = PydanticUndefined
total: int = 0
indexing: bool = True
class MyEvalRunRow(ctfy.core.models.CtfyModel):
812class MyEvalRunRow(CtfyModel):
813    """One of your (or a team-mate's) evaluation runs, for the shelf.
814
815    ⚠️ **``harness_id`` and ``model_id``, never the synthetic entrant
816    id.** ``EvalRunState.team_id`` is a one-person team minted per
817    ``(entrant, competition)``, so a page that showed it would print a
818    string nobody recognises and imply a team that has no members.
819
820    ⚠️ ``mine`` is composed **server-side**. Both the run's author and
821    the caller reach the browser on other surfaces, so a component
822    *could* compare them — and then two clients would disagree about
823    whether the conversation button is enabled. Same argument
824    ``expects_report`` makes.
825    """
826
827    #: How many times this entrant has rebuilt this challenge's
828    #: environment, from the permanent instance history.
829    #:
830    #: ⚠️ **``None`` means this surface did not say**, never "never
831    #: reset". The shelf withholds it — the count is a point query per
832    #: ``(team, challenge)`` and a fifty-row page would pay it fifty
833    #: times for a number nobody asked for — so only the single-run read
834    #: composes it. A ``0`` default would make the two surfaces disagree
835    #: about a run that *had* been reset, which is the one comparison
836    #: this field exists for: a model that reset five times and one that
837    #: never did are not like for like, and until now that difference was
838    #: invisible in the data.
839    resets: int | None = None
840
841    id: str = ""
842    harness_id: str = ""
843    model_id: str = ""
844    competition_id: str = ""
845    challenge_id: str = ""
846    #: The estate this run was dispatched against. A foreign key, so
847    #: ``instance_id`` rather than ``id`` — and the field the traffic
848    #: read is addressed by, which is why the row carries it at all: the
849    #: run knew which box it drove and the projection did not, so
850    #: nothing on the shelf could link to it.
851    instance_id: str = ""
852    status: str = ""
853    created_at_ts: float = 0.0
854    #: Whose run this is, for a team-wide list.
855    owner_user_id: str = ""
856    #: What that person is called, resolved for display — never the id,
857    #: which is already on the wire beside it. ``""`` means *nothing to
858    #: show*, never "we did not look": `ctfy.server.entity_labels` is the
859    #: one producer, and its third state is the whole reason this is not
860    #: composed ``label or owner``, which is what it was.
861    #:
862    #: ⚠️ **What that blank actually means here is "no display name", not
863    #: the convention's usual "deleted account".** `readable_runs`
864    #: derives its audience from membership rows and `delete_user`
865    #: cascades them, so a deleted owner drops out of the audience with
866    #: their run rather than appearing unnameable — measured, and pinned
867    #: by `test_a_deleted_account_is_not_listed_at_all`. Reading this
868    #: field's blank as a tombstone would be a sentence about a state
869    #: this route cannot reach.
870    #:
871    #: ⚠️ **`player_label`, so never an address.** The audience is the
872    #: caller's team-mates *at one competition* (`list_my_runs`), and
873    #: the roster they already open publishes a display name and no
874    #: email — `_author_name`'s split on the same audience.
875    owner_display_name: str = ""
876    #: Whether *this* caller may open the conversations. The list is the
877    #: team's; the bodies are the author's.
878    mine: bool = False
879    #: Why this run's traffic cannot be read, or ``""`` when it can.
880    #:
881    #: ⚠️ **A code, not a bool, and the client tests membership of
882    #: :data:`RUN_TRAFFIC_BLOCKED` rather than truthiness.** The three
883    #: closed cases each need their own sentence, and — the half that
884    #: decides the type — an older server sends no field at all, which
885    #: arrives as ``""``. A ``traffic_available: bool`` read as
886    #: ``=== false`` would then be *false* for everyone and the control
887    #: would vanish platform-wide with nothing reporting it. An
888    #: unrecognised value is likewise not blocked, so a server that
889    #: learns a fourth reason degrades to a live button rather than a
890    #: missing one.
891    traffic: str = ""
892    #: Why this run's collected archive cannot be downloaded, or ``""``
893    #: when it can.
894    #:
895    #: ⚠️ **Same type and same reading as ``traffic`` one field up**, and
896    #: for the same reasons: the closed cases are the set, everything
897    #: outside it is open, and an older server's absent field arrives as
898    #: ``""`` and must read as *not blocked*.
899    #:
900    #: ⚠️ Fail-open here means a run from before collection existed
901    #: offers a button that answers 404. That is the accepted trade this
902    #: repository makes everywhere: a wrong ``True`` costs a click, a
903    #: wrong ``False`` takes the control away from everybody the day the
904    #: server learns a new reason.
905    artifact: str = ""
906    #: Size of the stored archive, so the page can say what a click will
907    #: cost before it costs it. ``0`` when there is nothing to download.
908    artifact_bytes: int = 0
909    #: Why this run's write-up cannot be read, or ``""`` when it can.
910    #:
911    #: ⚠️ **A code and not the prose, because the prose is capped at
912    #: 100 000 characters** and this row rides a page of fifty. Same
913    #: split the archive makes one field up: the shelf says whether
914    #: there is one, the text is addressed.
915    #:
916    #: ⚠️ Same type and same reading as ``traffic`` and ``artifact``
917    #: above — the closed cases are the set, everything outside it is
918    #: open, and an older server's absent field arrives as ``""`` and
919    #: must read as *not blocked*.
920    writeup: str = ""
921    #: What the run has spent so far, from the run row itself.
922    input_tokens: int = 0
923    output_tokens: int = 0
924    #: How many calls the gateway actually relayed for this run. ``0``
925    #: beside a ``usage_source`` of ``agent`` is the pair that says the
926    #: two token counts above are the agent's own word about itself.
927    relayed_calls: int = 0
928    #: Who counted those figures — ``gateway`` where this deployment
929    #: measured them, ``agent`` where the run reported its own.
930    #:
931    #: ⚠️ **The default withholds, and ``"agent"`` would be a claim in
932    #: the accusing direction.** The shelf's warning line keys on
933    #: ``agent``, so a producer that forgot to forward this field would
934    #: accuse every row of a working deployment. ``""`` is *this server
935    #: did not say* — which is also how an older server's absent field
936    #: arrives — and it is what lets
937    #: `TestWhatTheRunSpentIsTheCountedNumber`'s unmeasured arm fail at
938    #: all: a default of ``agent`` agrees with the value that arm
939    #: asserts, so the mutation deleting both forwarding lines left it
940    #: green and only the measured arm reddened.
941    #:
942    #: ⚠️ **``str``, never a ``Literal``** — `KeySpec`'s reason. The one
943    #: reader compares *positively* against ``agent``, so a provenance a
944    #: newer server learns degrades to silence rather than becoming
945    #: unrepresentable in the client's own fail-quiet test.
946    usage_source: str = ""
947    #: Whether this run's **entrant** is on the event's projector wall.
948    #:
949    #: ⚠️ **A property of the entrant, not of the run**, and the field
950    #: says so rather than pretending otherwise:
951    #: ``TeamState.wall_published_at`` is per ``(owner, harness, model,
952    #: competition)`` because ``llm_calls`` has no run column, so a
953    #: second run of the same agent in the same event reads the same
954    #: value. The shelf renders the control per row anyway — a row is
955    #: where an author is standing — and the copy names the agent
956    #: rather than the run.
957    #:
958    #: ⚠️ **Defaults ``False``** because that is the state an author
959    #: never asked to leave: a producer that forgot this field would
960    #: otherwise draw a row as published on a deployment where nothing
961    #: is. The opposite default is a new claim, and here the claim is
962    #: somebody's consent.
963    wall_published: bool = False
964    #: Why this run's author cannot put it on the wall, or ``""`` when
965    #: they can.
966    #:
967    #: ⚠️ **Composed server-side**, ``mine`` and ``traffic``'s rule:
968    #: every fact it reads reaches the client some other way, so a
969    #: component *could* derive it — and then two clients would
970    #: disagree about whether the control is live, in the invisible
971    #: direction. Each member needs
972    #: ``settings:agentRuns.wall.<code>`` copy in both locales; the key
973    #: is computed at the call site, so neither frontend i18n scanner
974    #: can see it and ``test_run_wall_copy_parity.py`` pins it from
975    #: :data:`RUN_WALL_BLOCKED` instead.
976    wall: str = ""
977    #: Whether this run's **entrant** has published its full conversation
978    #: (B2). ``TeamState.transcript_published_at``, the author's second,
979    #: stronger consent — the wall shows one line, this opens the whole
980    #: scrubbed conversation. Defaults ``False`` for ``wall_published``'s
981    #: reason: the state an author never asked to leave.
982    transcript_published: bool = False
983    #: Why this run's author cannot publish the full conversation, or
984    #: ``""`` when they can. Composed server-side, ``wall``'s rule. Its
985    #: extra member over the wall is ``anonymous``: a conversation
986    #: de-anonymises, so a pseudonymous/hidden entrant — publishable to
987    #: the wall under an alias — can never publish a transcript. Each
988    #: member needs ``settings:agentRuns.transcript.<code>`` copy in both
989    #: locales; ``test_run_wall_copy_parity.py`` pins it from
990    #: :data:`RUN_TRANSCRIPT_BLOCKED`.
991    transcript: str = ""

One of your (or a team-mate's) evaluation runs, for the shelf.

⚠️ harness_id and model_id, never the synthetic entrant id. EvalRunState.team_id is a one-person team minted per (entrant, competition), so a page that showed it would print a string nobody recognises and imply a team that has no members.

⚠️ mine is composed server-side. Both the run's author and the caller reach the browser on other surfaces, so a component could compare them — and then two clients would disagree about whether the conversation button is enabled. Same argument expects_report makes.

resets: int | None = None
id: str = ''
harness_id: str = ''
model_id: str = ''
competition_id: str = ''
challenge_id: str = ''
instance_id: str = ''
status: str = ''
created_at_ts: float = 0.0
owner_user_id: str = ''
owner_display_name: str = ''
mine: bool = False
traffic: str = ''
artifact: str = ''
artifact_bytes: int = 0
writeup: str = ''
input_tokens: int = 0
output_tokens: int = 0
relayed_calls: int = 0
usage_source: str = ''
wall_published: bool = False
wall: str = ''
transcript_published: bool = False
transcript: str = ''
class MyLlmKey(ctfy.core.models.CtfyModel):
459class MyLlmKey(CtfyModel):
460    """One of the caller's own upstream credentials, as they may see it.
461
462    ⚠️ **There is no ``api_key`` field, and its absence is the design.**
463    A user pastes a provider key once; every read after that is a list
464    they can tell apart, never one they can copy — and neither can
465    anybody who reaches this endpoint holding their bearer. A field
466    carrying ``""`` would satisfy every "the secret is not in the
467    response" test while leaving the next writer one line from
468    populating it.
469    """
470
471    flavor: FlavorOrUnset = ""
472    label: str = ""
473    base_url: str = ""
474    #: The key's last four characters, so a rotated key is
475    #: distinguishable from its predecessor. The *suffix*: provider keys
476    #: carry a recognisable prefix that says nothing about which key this
477    #: is, and the tail is what every provider's own console shows.
478    fingerprint: str = ""
479    added_at: str = ""
480    #: ``""`` means no run has spent it yet — a real answer, not a
481    #: missing one.
482    last_used_at: str = ""
483    #: What the last probe of this credential found, and when.
484    #:
485    #: ⚠️ **A pair, and both are rendered or neither.** A stored code
486    #: with no instant is the stale-marker trap: a key rejected once and
487    #: rotated since would read ``auth_failed`` for ever with nothing
488    #: saying the reading predates the fix.
489    #:
490    #: ⚠️ Both ``""`` is *never probed*, which is not a tenth verdict —
491    #: ``gateway_unreachable`` already means *a probe happened and this
492    #: deployment could not tell you*, and the two get opposite advice.
493    last_probe_code: str = ""
494    last_probe_at: str = ""

One of the caller's own upstream credentials, as they may see it.

⚠️ There is no api_key field, and its absence is the design. A user pastes a provider key once; every read after that is a list they can tell apart, never one they can copy — and neither can anybody who reaches this endpoint holding their bearer. A field carrying "" would satisfy every "the secret is not in the response" test while leaving the next writer one line from populating it.

flavor: Union[ctfy.core.enums.Flavor, Literal['']] = ''
label: str = ''
base_url: str = ''
fingerprint: str = ''
added_at: str = ''
last_used_at: str = ''
last_probe_code: str = ''
last_probe_at: str = ''
class MyLlmKeyCreate(ctfy.core.models.CtfyModel):
537class MyLlmKeyCreate(CtfyModel):
538    """A credential as its owner types it.
539
540    ⚠️ The dialect is **not** here: it is the path segment, so a body
541    naming one dialect while the URL names another is unrepresentable
542    rather than a precedence rule somebody has to remember.
543    """
544
545    api_key: str = Field(min_length=1, max_length=512)
546    label: str = Field(default="", max_length=64)
547    base_url: str = Field(default="", max_length=200)

A credential as its owner types it.

⚠️ The dialect is not here: it is the path segment, so a body naming one dialect while the URL names another is unrepresentable rather than a precedence rule somebody has to remember.

api_key: str = PydanticUndefined
label: str = ''
base_url: str = ''
class MyLlmKeyProbe(ctfy.core.models.CtfyModel):
497class MyLlmKeyProbe(CtfyModel):
498    """What one press of Test on one of the caller's own keys found.
499
500    ⚠️ **No ``key_id``**, unlike the operator's :class:`LlmProviderProbe`.
501    The client never holds one — it addresses a key by
502    ``(flavor, base_url)``, which is the identity the server keys on —
503    so publishing the derived ``byok:<uuid>:…`` id back would put an
504    internal id shape on the wire for no reader at all. The pair it did
505    send is echoed instead, so a card keying its results by provider can
506    file the answer without trusting request order.
507
508    ⚠️ **``ok`` defaults ``False`` and ``code`` defaults ``""``**, the
509    not-a-new-claim direction: a producer that forgets must not report a
510    credential as working.
511    """
512
513    flavor: FlavorOrUnset = ""
514    base_url: str = ""
515    ok: bool = False
516    #: The bucket to act on. ``ok`` / ``auth_failed`` / ``quota_exceeded``
517    #: / ``unreachable`` / ``bad_request`` from the provider,
518    #: ``no_gateway`` / ``gateway_unreachable`` / ``not_synced`` /
519    #: ``unknown`` from this platform, and ``no_model_registered`` when
520    #: nothing a run could dispatch names this address.
521    #:
522    #: ⚠️ A **code, never a boolean**: each one sends the reader to a
523    #: different field, and one red cross reports them identically.
524    code: str = ""
525    #: The provider's verbatim status; ``0`` when nothing answered.
526    status: int = 0
527    #: The provider's own words, bounded by the gateway.
528    detail: str = ""
529    #: What the model actually said. ⚠️ Empty is a *result* — a 200 that
530    #: generated no text is the shape a status code cannot see.
531    reply: str = ""
532    #: The gateway is running its offline stub, so ``ok`` here proves
533    #: nothing about a real provider. Rendered **beside** the verdict.
534    stub: bool = False

What one press of Test on one of the caller's own keys found.

⚠️ No key_id, unlike the operator's LlmProviderProbe. The client never holds one — it addresses a key by (flavor, base_url), which is the identity the server keys on — so publishing the derived byok:<uuid>:… id back would put an internal id shape on the wire for no reader at all. The pair it did send is echoed instead, so a card keying its results by provider can file the answer without trusting request order.

⚠️ ok defaults False and code defaults "", the not-a-new-claim direction: a producer that forgets must not report a credential as working.

flavor: Union[ctfy.core.enums.Flavor, Literal['']] = ''
base_url: str = ''
ok: bool = False
code: str = ''
status: int = 0
detail: str = ''
reply: str = ''
stub: bool = False
class MyReactionsResponse(ctfy.core.models.CtfyModel):
14class MyReactionsResponse(CtfyModel):
15    """The calling user's active reaction chips on a challenge.
16
17    Multi-select: a player can stack any subset of the 9 reactions.
18    ``reactions`` is the unordered set the player has currently
19    toggled on; empty list means none. ``updated_at`` is the most
20    recent change among the active rows, used as a hydration hint
21    by the optimistic UI.
22    """
23
24    reactions: list[Reaction] = Field(default_factory=list)
25    updated_at: datetime | None = None

The calling user's active reaction chips on a challenge.

Multi-select: a player can stack any subset of the 9 reactions. reactions is the unordered set the player has currently toggled on; empty list means none. updated_at is the most recent change among the active rows, used as a hydration hint by the optimistic UI.

reactions: list[typing.Literal['addictive', 'mindblown', 'learned', 'goat_setter', 'overthought', 'guessy', 'brutal', 'buggy', 'infra_broken']] = PydanticUndefined
updated_at: datetime.datetime | None = None
class MyRegistrationResponse(ctfy.core.models.CtfyModel):
136class MyRegistrationResponse(CtfyModel):
137    """``GET /competitions/{id}/registration`` — the caller's own dossier.
138
139    Carries the form spec alongside the values so the wizard can render
140    from one round trip, and ``missing_required`` so it can tell the user
141    exactly what still blocks submission without reimplementing the
142    server's rule.
143    """
144
145    competition_id: str
146    form: RegistrationFormSpec
147    registered: bool = False
148    submitted_at: datetime | None = None
149    updated_at: datetime | None = None
150    missing_required: list[str] = Field(default_factory=list)
151
152    real_name: str = ""
153    phone: str = ""
154    mailing_address: str = ""
155    # ⚠️ The same closed set the request carries, not a bare ``str``.
156    # It was ``str`` here while ``RegistrationSubmitRequest.gender``
157    # was a ``Literal`` and the storage column was too — three
158    # layers with the middle one dropping the type, the shape
159    # ``ScopeCatalogEntry.max_level`` already records. A client
160    # reads this and writes that, so the loose middle made the
161    # round trip fail to type-check in the one direction that
162    # matters: `tsc` reported it the moment the wire type stopped
163    # being hand-written on the frontend.
164    gender: Literal["", "male", "female", "other", "undisclosed"] = ""
165    organization: str = ""
166    academic_stage: str = ""
167    grade: str = ""
168    undergrad_school: str = ""
169    specialties: list[str] = Field(default_factory=list)
170
171    # Team-scoped answers, echoed so the captain's wizard round-trips.
172    team_id: str = ""
173    team_name: str = ""
174    team_honors: str = ""
175    team_has_logo: bool = False
176    is_captain: bool = False
177    review_status: RegistrationReviewStatus = "approved"
178    review_note: str = ""
179    #: The two anonymity answers as stored, so the wizard round-trips.
180    #: Booleans here rather than the request's tri-state: this is what
181    #: *is*, and "the form did not ask" is not a state a stored row can
182    #: be in.
183    anonymous: bool = False
184    team_anonymous: bool = False

GET /competitions/{id}/registration — the caller's own dossier.

Carries the form spec alongside the values so the wizard can render from one round trip, and missing_required so it can tell the user exactly what still blocks submission without reimplementing the server's rule.

competition_id: str = PydanticUndefined
form: RegistrationFormSpec = PydanticUndefined
registered: bool = False
submitted_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
missing_required: list[str] = PydanticUndefined
real_name: str = ''
phone: str = ''
mailing_address: str = ''
gender: Literal['', 'male', 'female', 'other', 'undisclosed'] = ''
organization: str = ''
academic_stage: str = ''
grade: str = ''
undergrad_school: str = ''
specialties: list[str] = PydanticUndefined
team_id: str = ''
team_name: str = ''
team_honors: str = ''
is_captain: bool = False
review_status: Literal['pending', 'approved', 'rejected'] = 'approved'
review_note: str = ''
anonymous: bool = False
team_anonymous: bool = False
class MyReportRow(ctfy.core.models.CtfyModel):
151class MyReportRow(CtfyModel):
152    """One range this team has written up, as the team's own index reads it.
153
154    ⚠️ **Not a narrowed :class:`ReportRosterRow`.** That one answers "who
155    has started" for an organiser and carries a team; this answers "which
156    of my ranges have I written up" and every row is the caller's own, so
157    a team column there would be the same value repeated down the page.
158    """
159
160    challenge_id: str = ""
161    version: int = 0
162    length: int = 0
163    saved_at: datetime | None = None
164    #: Where this range's write-up stands with the report grader.
165    status: ReportGradeStatus = "not_scored"
166    #: 1-based place in the grading queue — **only** while ``status`` is
167    #: ``queued``.
168    #:
169    #: ⚠️ ``0`` is *no position*, and it covers two facts on purpose:
170    #: this report is not queued, and this competition is silent so the
171    #: number is withheld. They are told apart by ``status``, which is
172    #: the field that already has to be read first — and collapsing them
173    #: is what keeps a client from rendering "#0 in queue", which is the
174    #: one output a separate "unknown" member would invite.
175    queue_position: int = 0
176    #: When this team handed this range in, or ``None``.
177    finalized_at: datetime | None = None
178    #: Grading requests this team has spent on this range — the
179    #: numerator behind the button's "2 left".
180    #:
181    #: ⚠️ **Per row, because the quota is.** It is keyed
182    #: ``(team, range)``, so a team that has spent everything on one
183    #: estate still has its full allowance on the next — and a single
184    #: number on the response would say the opposite.
185    grade_requests_used: int = 0

One range this team has written up, as the team's own index reads it.

⚠️ Not a narrowed ReportRosterRow. That one answers "who has started" for an organiser and carries a team; this answers "which of my ranges have I written up" and every row is the caller's own, so a team column there would be the same value repeated down the page.

challenge_id: str = ''
version: int = 0
length: int = 0
saved_at: datetime.datetime | None = None
status: Literal['not_scored', 'waiting', 'queued', 'graded'] = 'not_scored'
queue_position: int = 0
finalized_at: datetime.datetime | None = None
grade_requests_used: int = 0
class MyReportsResponse(ctfy.core.models.CtfyModel):
188class MyReportsResponse(CtfyModel):
189    """This team's report index for one competition.
190
191    ⚠️ **Only ranges with a saved report appear.** The ranges the team
192    has *not* written up are the competition's challenge list minus
193    these, which the client already holds — and listing them here would
194    mean this route deciding what "the competition's ranges" are, a
195    second answer to a question ``effective_challenge_ids`` already owns.
196    """
197
198    competition_id: str = ""
199    rows: list[MyReportRow] = Field(default_factory=list)
200    #: How many reports are queued for grading across the whole
201    #: competition — the denominator behind ``queue_position``.
202    #:
203    #: ⚠️ **Served, unlike the organiser's, because this client cannot
204    #: count it.** Every row here is the caller's own team, so a total
205    #: derived from them would say "1 of 1" for a team third in a queue
206    #: of twelve.
207    #:
208    #: ⚠️ **Zero in a silent competition**, along with every
209    #: ``queue_position``. How many teams have a report in flight is the
210    #: field's progress, which is the thing that format exists to
211    #: withhold — the same read as ``solves_count`` and ``flag_solves``.
212    #: The per-row ``status`` survives, because "your report is queued"
213    #: is a fact about the caller's own submission and about nobody
214    #: else's.
215    queue_length: int = 0
216    #: The per-range ceiling on hand-pushed grading. ⚠️ **``0`` is
217    #: unlimited**, and it is served rather than restated in the client
218    #: for ``ReportAttachmentsResponse.remaining``'s reason: a ceiling
219    #: spelled in two places is one a client eventually disagrees with,
220    #: and the direction it disagrees in is a disabled button on a
221    #: deployment that had room.
222    #:
223    #: ⚠️ **Not withheld in a silent competition.** It is a fact about
224    #: the caller's own allowance, not about the field — the same split
225    #: that keeps ``status`` while dropping ``queue_position``.
226    grade_requests_limit: int = 0

This team's report index for one competition.

⚠️ Only ranges with a saved report appear. The ranges the team has not written up are the competition's challenge list minus these, which the client already holds — and listing them here would mean this route deciding what "the competition's ranges" are, a second answer to a question effective_challenge_ids already owns.

competition_id: str = ''
rows: list[MyReportRow] = PydanticUndefined
queue_length: int = 0
grade_requests_limit: int = 0
class MySolveSummary(ctfy.core.models.CtfyModel):
 996class MySolveSummary(CtfyModel):
 997    """Per-challenge solve summary for the calling **user**.
 998
 999    Returned by ``GET /me/solves``. One row per challenge the caller
1000    personally captured at least one question of. ``best_rank`` is the
1001    smallest rank across those captures — drives the 1血/2血/3血 badge.
1002    Multi-flag challenges may have different ranks per flag; reporting
1003    the best one gives players credit for whichever piece they nailed
1004    first.
1005
1006    ⚠️ **The subject is the person, and this docstring said "team" for
1007    long enough that a client believed it.** The route filters
1008    ``Solve.user_id`` — the human who submitted — while every scoring
1009    surface on this platform keys on ``team_id``, so a team-mate who had
1010    not personally submitted got no row at all and the challenge card
1011    rendered 未解出 over a challenge their team already owned. That is a
1012    profile page's honest answer and a challenge card's wrong one; the
1013    card reads ``ChallengeInfo.my_team_solved_question_ids`` now, and
1014    this stays what it always was.
1015    """
1016
1017    challenge_id: str
1018    best_rank: int
1019    solved_at: datetime | None = None
1020    # Whether **every** capture the team holds on this challenge is on
1021    # the competition's board.
1022    #
1023    # ⚠️ ``all``, not ``any``: the card renders this as "some of what you
1024    # captured here does not count", and a partial split has to say so.
1025    # ``any`` would stay quiet about two uncounted flags out of three,
1026    # which is the same silence as before the field existed.
1027    #
1028    # Unscoped (``competition_id=""``) this is always ``True``: the
1029    # cross-competition history applies no window, so no capture there
1030    # is "not counted" — see ``/me/solves``.
1031    ranked: bool = True

Per-challenge solve summary for the calling user.

Returned by GET /me/solves. One row per challenge the caller personally captured at least one question of. best_rank is the smallest rank across those captures — drives the 1血/2血/3血 badge. Multi-flag challenges may have different ranks per flag; reporting the best one gives players credit for whichever piece they nailed first.

⚠️ The subject is the person, and this docstring said "team" for long enough that a client believed it. The route filters Solve.user_id — the human who submitted — while every scoring surface on this platform keys on team_id, so a team-mate who had not personally submitted got no row at all and the challenge card rendered 未解出 over a challenge their team already owned. That is a profile page's honest answer and a challenge card's wrong one; the card reads ChallengeInfo.my_team_solved_question_ids now, and this stays what it always was.

challenge_id: str = PydanticUndefined
best_rank: int = PydanticUndefined
solved_at: datetime.datetime | None = None
ranked: bool = True
class NodeAuthoredPush(ctfy.core.models.CtfyModel):
1113class NodeAuthoredPush(CtfyModel):
1114    """Platform → node: put this approved upload in your catalog.
1115
1116    ⚠️ **The bytes travel, and that is the one thing this cannot borrow
1117    from :class:`NodeRangePush`.** That model sends a *scenario* and
1118    argues that files must not cross the node API — right, because the
1119    corpus is mounted on the node and the compiler ships inside it. A
1120    player-authored tree has neither: the files *are* the source, the
1121    same reason `AuthoredChallengeState` stores bytes where
1122    `GeneratedRangeState` stores knobs.
1123
1124    ⚠️ **An archive rather than the stored `content_b64` map**, because
1125    that map is the tree as base64, uncompressed — a POST a third larger
1126    than `MAX_TOTAL_BYTES` at the ceiling — and
1127    because `read_archive` + `plan_extraction` are then the *same*
1128    producers the upload door used, re-asked on the machine where an
1129    escaped member would be an arbitrary write on a worker.
1130    """
1131
1132    challenge_id: str
1133    #: `base64(pack_tree(...))` — a gzipped tar carrying exactly one
1134    #: leading directory, the shape `read_archive` strips.
1135    archive_b64: str = ""
1136    #: `tree_revision` of the tree the archive carries (#1835). The node
1137    #: re-derives it from what it extracted and refuses a disagreement, so
1138    #: the name it later answers a start with is the name of what it holds.
1139    #: ``""`` — what an older platform sends — asserts nothing and is
1140    #: checked against nothing.
1141    revision: str = ""

Platform → node: put this approved upload in your catalog.

⚠️ The bytes travel, and that is the one thing this cannot borrow from NodeRangePush. That model sends a scenario and argues that files must not cross the node API — right, because the corpus is mounted on the node and the compiler ships inside it. A player-authored tree has neither: the files are the source, the same reason AuthoredChallengeState stores bytes where GeneratedRangeState stores knobs.

⚠️ An archive rather than the stored content_b64 map, because that map is the tree as base64, uncompressed — a POST a third larger than MAX_TOTAL_BYTES at the ceiling — and because read_archive + plan_extraction are then the same producers the upload door used, re-asked on the machine where an escaped member would be an arbitrary write on a worker.

challenge_id: str = PydanticUndefined
archive_b64: str = ''
revision: str = ''
class NodeAuthoredResult(ctfy.core.models.CtfyModel):
1144class NodeAuthoredResult(CtfyModel):
1145    """Node → platform: what this node did with a pushed upload.
1146
1147    ⚠️ ``files`` rather than a bare ``ok``: a tree that wrote zero files
1148    and one that wrote forty both answer "ok", and only the count
1149    separates a real push from an archive that planned nothing.
1150    """
1151
1152    challenge_id: str = ""
1153    ok: bool = False
1154    #: How many files landed. `0` on any refusal.
1155    files: int = 0
1156    #: The revision this node now holds, derived from the files it wrote.
1157    #: ``""`` on any refusal.
1158    revision: str = ""
1159    #: Why not. Empty on success — never a generic failure, because the
1160    #: repairs differ: a refused member is fixed by the author, an
1161    #: unwritable catalog on the node's host.
1162    error: str = ""

Node → platform: what this node did with a pushed upload.

⚠️ files rather than a bare ok: a tree that wrote zero files and one that wrote forty both answer "ok", and only the count separates a real push from an archive that planned nothing.

challenge_id: str = ''
ok: bool = False
files: int = 0
revision: str = ''
error: str = ''
class NodeAuthoredSmokePush(ctfy.core.models.CtfyModel):
1165class NodeAuthoredSmokePush(CtfyModel):
1166    """Platform → node: run this authored challenge's own exploit here.
1167
1168    ⚠️ **The id, not the tree.** Unlike :class:`NodeAuthoredPush` there
1169    is nothing to send: the smoke run is *of the copy this node already
1170    holds*, and a push that carried bytes would be smoking an estate no
1171    player will ever launch.
1172    """
1173
1174    challenge_id: str
1175    #: Seconds. `0` means the node's own default — never "no limit",
1176    #: which on a wedged compose is a worker slot held for ever. A
1177    #: *per-step* budget (`ctfy.core.smoke_budget`), so the platform
1178    #: sends it explicitly and waits `worst_case_seconds` of it.
1179    timeout_s: int = 0
1180    #: Leave nothing behind (plan P2.2): teardown removes every image the
1181    #: run used, pulled ones included, and the build cache is pruned.
1182    #: Sent for a quarantine node only — whose contract is that one
1183    #: unread tree cannot hand anything to the next — and never for the
1184    #: published pool, where the cache is what keeps a player's launch
1185    #: fast.
1186    reset: bool = False
1187    #: The revision the run must be of (#1835): a node holding another
1188    #: fetches the current tree first, so a smoke never reports on bytes
1189    #: that are no longer the challenge. ``""`` = whatever the node holds.
1190    revision: str = ""

Platform → node: run this authored challenge's own exploit here.

⚠️ The id, not the tree. Unlike NodeAuthoredPush there is nothing to send: the smoke run is of the copy this node already holds, and a push that carried bytes would be smoking an estate no player will ever launch.

challenge_id: str = PydanticUndefined
timeout_s: int = 0
reset: bool = False
revision: str = ''
class NodeAuthoredSmokeResult(ctfy.core.models.CtfyModel):
1193class NodeAuthoredSmokeResult(CtfyModel):
1194    """Node → platform: what the smoke run concluded.
1195
1196    ⚠️ ``verdict`` rather than a bare ``ok``: the four outcomes are four
1197    repairs by different people — see :data:`AuthoredSmokeVerdict`. The
1198    exit code rides along because an ``errored`` verdict on a code we do
1199    not recognise is only actionable if the reader can see the code.
1200    """
1201
1202    challenge_id: str = ""
1203    verdict: AuthoredSmokeVerdict = "errored"
1204    #: `smoke_benchmark`'s own exit code. `-1` when it never ran.
1205    exit_code: int = -1
1206    error: str = ""

Node → platform: what the smoke run concluded.

⚠️ verdict rather than a bare ok: the four outcomes are four repairs by different people — see AuthoredSmokeVerdict. The exit code rides along because an errored verdict on a code we do not recognise is only actionable if the reader can see the code.

challenge_id: str = ''
verdict: Literal['solved', 'unsolved', 'estate_failed', 'malformed', 'errored'] = 'errored'
exit_code: int = -1
error: str = ''
class NodeAuthoredSyncLine(ctfy.core.models.CtfyModel):
1287class NodeAuthoredSyncLine(CtfyModel):
1288    """One node's outcome in an authored-upload push.
1289
1290    ⚠️ **Its own model rather than :class:`NodeRangeSyncLine` with an
1291    empty `corpus_commit`.** That field is the whole point of the range
1292    line — two nodes can both succeed and hold different estates, and
1293    only the commit says so — and it has no meaning here, where the
1294    bytes are what travelled. A field that asserts nothing on one of two
1295    users of a shared model is the `CompetitionAccess` trap: one name,
1296    two axes. ``files`` is this push's equivalent witness.
1297
1298    ⚠️ Three states, not two: ``ok``, refused (``error`` from the node),
1299    and unreachable (``error`` from the transport) — the repairs are on
1300    different machines.
1301    """
1302
1303    node_id: str = ""
1304    ok: bool = False
1305    #: How many files the node wrote. `0` on any refusal.
1306    files: int = 0
1307    error: str = ""

One node's outcome in an authored-upload push.

⚠️ Its own model rather than NodeRangeSyncLine with an empty corpus_commit. That field is the whole point of the range line — two nodes can both succeed and hold different estates, and only the commit says so — and it has no meaning here, where the bytes are what travelled. A field that asserts nothing on one of two users of a shared model is the CompetitionAccess trap: one name, two axes. files is this push's equivalent witness.

⚠️ Three states, not two: ok, refused (error from the node), and unreachable (error from the transport) — the repairs are on different machines.

node_id: str = ''
ok: bool = False
files: int = 0
error: str = ''
class NodeCaptureStoreReport(ctfy.core.models.CtfyModel):
176class NodeCaptureStoreReport(CtfyModel):
177    """What a node's capture store holds, and what its last pass did (#1714).
178
179    The node measures ``<proxy_output_host_root>/`` — the per-instance
180    tcpdump and mitmproxy output it writes and nothing else removes —
181    and sends the latest reading on every heartbeat, because the platform
182    cannot see another host's disk and the admin overview is where an
183    operator looks.
184
185    ⚠️ **``None`` on the heartbeat is "not measured", never zero.** A node
186    from before this field, or one that has not finished its first pass,
187    sends nothing; a zero here is a measurement.
188    """
189
190    #: Unix seconds the store was last walked.
191    measured_at: float = 0.0
192    status: PassStatus = PassStatus.OK
193    #: The capture root on the node's own filesystem; ``""`` with
194    #: ``status=no_store`` (captures live in instance workdirs).
195    root: str = ""
196    instances: int = 0
197    total_bytes: int = 0
198    #: Held by instances no box is running from — the most any window
199    #: can reclaim.
200    finished_bytes: int = 0
201    #: Of those, past the retention window when the pass planned.
202    due_bytes: int = 0
203    #: The window and cap this pass applied. ``None`` is *the platform
204    #: did not say*, which reclaims nothing.
205    retention_s: int | None = None
206    cap_bytes: int | None = None
207    reclaimed_instances: int = 0
208    reclaimed_bytes: int = 0
209    #: Removals that failed (a permission, a vanished file). The rest of
210    #: the pass went ahead; these are retried next pass.
211    failed: int = 0
212    kept: dict[KeepReason, int] = Field(default_factory=dict)
213    skipped: dict[SkipReason, int] = Field(default_factory=dict)
214    #: Directories whose capture the per-instance cap has trimmed, and
215    #: the bytes it dropped — read off each one's truncation marker, so
216    #: this is what the disk says rather than what one process counted.
217    truncated_instances: int = 0
218    truncated_bytes: int = 0
219    #: Live instances still over the cap after every closed segment went
220    #: (only tcpdump's open files remain).
221    over_cap_instances: int = 0
222    #: Why the pass did less than its status suggests, when it did.
223    error: str = ""

What a node's capture store holds, and what its last pass did (#1714).

The node measures <proxy_output_host_root>/ — the per-instance tcpdump and mitmproxy output it writes and nothing else removes — and sends the latest reading on every heartbeat, because the platform cannot see another host's disk and the admin overview is where an operator looks.

⚠️ None on the heartbeat is "not measured", never zero. A node from before this field, or one that has not finished its first pass, sends nothing; a zero here is a measurement.

measured_at: float = 0.0
status: ctfy.core.instance_disk_retention.PassStatus = <PassStatus.OK: 'ok'>
root: str = ''
instances: int = 0
total_bytes: int = 0
finished_bytes: int = 0
due_bytes: int = 0
retention_s: int | None = None
cap_bytes: int | None = None
reclaimed_instances: int = 0
reclaimed_bytes: int = 0
failed: int = 0
kept: dict[ctfy.core.instance_disk_retention.KeepReason, int] = PydanticUndefined
skipped: dict[ctfy.core.instance_disk_retention.SkipReason, int] = PydanticUndefined
truncated_instances: int = 0
truncated_bytes: int = 0
over_cap_instances: int = 0
error: str = ''
class NodeCaptureStoreRow(ctfy.core.models.CtfyModel):
280class NodeCaptureStoreRow(CtfyModel):
281    """One node's capture store, as its last heartbeat reported it."""
282
283    node_id: str
284    node_name: str = ""
285    #: When the platform received the reading; ``0`` = never, since the
286    #: platform started.
287    received_at: float = 0.0
288    #: ``None`` = not measured (an older node, or its first pass has not
289    #: finished). Never read as an empty store.
290    report: NodeCaptureStoreReport | None = None

One node's capture store, as its last heartbeat reported it.

node_id: str = PydanticUndefined
node_name: str = ''
received_at: float = 0.0
report: NodeCaptureStoreReport | None = None
class NodeHeartbeat(ctfy.core.models.CtfyModel):
267class NodeHeartbeat(CtfyModel):
268    """Body for ``POST /nodes/heartbeat``.
269
270    Node reports its live counts + resource utilisation on every beat.
271    The percentages are 0–100; 0 is a safe default for the first beat
272    where psutil hasn't had a prior sample to diff against.
273    """
274
275    id: str
276    running: int = 0
277    capacity: int = 50
278    cpu_percent: float = 0.0
279    memory_percent: float = 0.0
280    # Physical RAM in MiB (not a percentage). Memory-aware admission
281    # needs the absolute size; 0 = not reported, which older node builds
282    # send and which admission reads as "unknown, fall back to slots".
283    memory_total_mb: float = 0.0
284    disk_percent: float = 0.0
285    # Unix seconds when the node sampled the metrics. The server stores
286    # ``received_at - sampled_at`` as one-way latency on the resulting
287    # health sample. Default 0 means "not measured" — older node builds
288    # that haven't been upgraded keep working.
289    sampled_at: float = 0.0
290    # The ctfy version the node itself is running. Empty on a node that
291    # predates the field, which reads as "unknown" and is never a skew —
292    # the same rule ``memory_total_mb``'s 0 already follows, so an
293    # un-upgraded fleet stays quiet instead of alarming on every beat.
294    version: str = ""
295    # What this node is actually running, as two facts `version` cannot
296    # carry. ⚠️ **`""` is *unreadable*, never a mismatch** — see the
297    # same pair on :class:`NodeInfo` for the deployment this was
298    # measured on and why semver is not enough.
299    git_commit: str = ""
300    corpus_commit: str = ""
301    # Base64 X25519 public key for this node's half of an AWD match
302    # tunnel. Bounded here rather than validated: the *shape* is checked
303    # once, at plan time, where a refusal can name the node and the match
304    # it broke (`core/awd_fabric.py`); a second validator on the hottest
305    # node→platform path would be a second answer to one question. The
306    # length cap is not that validator — it stops an unbounded string
307    # reaching the column at all.
308    wireguard_public_key: str = Field(default="", max_length=64)
309    #: Host other nodes dial for the fabric. ⚠️ A host, not a
310    #: ``host:port``: the port is the fabric plan's, and a node
311    #: naming one would be a second answer to a question the plan
312    #: already settles.
313    wireguard_host: str = Field(default="", max_length=255)
314    #: The node's latest capture-store reading (#1714). ``None`` is *not
315    #: measured* — an older node, or one mid-first-pass.
316    capture_store: NodeCaptureStoreReport | None = None
317    #: This node's ``repository:tag -> RepoDigest sha`` for registry-sourced
318    #: images, for the fleet image-consistency check. ⚠️ **Three-valued the
319    #: way ``capture_store`` is**: ``None`` is *did not measure this beat* —
320    #: the scan is hourly, or the daemon was unreadable — and the platform
321    #: keeps the last map; a map (even ``{}``) replaces it. Defaulting to
322    #: ``{}`` would make every beat between hourly scans clear a node's map
323    #: and hide a live divergence.
324    image_digests: dict[str, str] | None = None
325    #: The node's last build-egress sync (#1868). ``None`` is *not
326    #: reported* — an older node, one whose sync has not run yet, or one
327    #: the platform has never answered with ``untrusted_builds`` — and
328    #: keeps whatever the row last held, the keep-last rule ``version``
329    #: follows.
330    build_egress: NodeBuildEgressReport | None = None
331    #: The node's last helper-image check (#1875). ``None`` is *not
332    #: reported* — an older node, or one whose first check has not
333    #: finished — and keeps whatever the row last held.
334    helper_images: NodeHelperImagesReport | None = None
335    #: Where this node's ingress canary answers (#1903) — the public URL a
336    #: player would use for a box here, under the canary's fixed label.
337    #: ``None`` is *not reported* (an older node) and keeps the row's value;
338    #: ``""`` is *this node runs no canary* and clears it. The platform
339    #: dials it only if it is the canary's shape (`canary_address`).
340    ingress_canary_url: str | None = Field(default=None, max_length=512)

Body for POST /nodes/heartbeat.

Node reports its live counts + resource utilisation on every beat. The percentages are 0–100; 0 is a safe default for the first beat where psutil hasn't had a prior sample to diff against.

id: str = PydanticUndefined
running: int = 0
capacity: int = 50
cpu_percent: float = 0.0
memory_percent: float = 0.0
memory_total_mb: float = 0.0
disk_percent: float = 0.0
sampled_at: float = 0.0
version: str = ''
git_commit: str = ''
corpus_commit: str = ''
wireguard_public_key: str = ''
wireguard_host: str = ''
capture_store: NodeCaptureStoreReport | None = None
image_digests: dict[str, str] | None = None
build_egress: ctfy.server.models.node.NodeBuildEgressReport | None = None
helper_images: ctfy.server.models.node.NodeHelperImagesReport | None = None
ingress_canary_url: str | None = None
class NodeHeartbeatResponse(ctfy.core.models.CtfyModel):
343class NodeHeartbeatResponse(CtfyModel):
344    """Body for ``POST /nodes/heartbeat``'s reply.
345
346    The beat used to answer a bare ``{"status": "ok"}``. It carries the
347    platform's authoritative live set for this node now, because that is
348    the only thing that lets the node's own record store shrink — see
349    :func:`ctfy.server.store_reconcile.retired_records` for what went
350    wrong without it.
351
352    ⚠️ **``None`` and ``[]`` are opposite facts and the difference is the
353    whole feature.** ``None`` means *this platform did not say* — an
354    older build, or a response the node could not read — and authorises
355    nothing. ``[]`` means *nothing is live on you*, which is
356    authoritative and is exactly the case a leaked node is in. Defaulting
357    the field to ``[]`` would make every old platform look like it had
358    just retired the fleet, so the default is ``None`` and the platform
359    always fills it.
360    """
361
362    status: str = "ok"
363    #: Instance ids the platform holds live *on this node*. Scoped to the
364    #: node on purpose: an unscoped set would make one node's records
365    #: answerable by another node's placements, and the failure mode of
366    #: getting that backwards is retiring live instances.
367    live_instance_ids: list[str] | None = None
368    #: Whether an operator has marked this node as one player-authored
369    #: code may be built on. The node cannot see the label — it lives on
370    #: the platform's `NodeState` — so it rides the beat, and the node
371    #: turns it into a firewall rule keeping a build away from other
372    #: networks' published container ports.
373    #:
374    #: ⚠️ **Three-valued for `live_instance_ids`' reason, and the third
375    #: value is the whole point.** ``None`` is *this platform did not
376    #: say* — an older build, or a body the node could not read — and
377    #: authorises nothing in either direction: the node neither installs
378    #: nor removes. ``False`` is authoritative and is what gives an
379    #: operator their firewall back when they unmark a node, so it can
380    #: never be conflated with silence.
381    untrusted_builds: bool | None = None
382    #: Match indexes (`awd_matches.network_index`) the platform holds live
383    #: on this node, so the node can reap a ``ctfy-awd-*`` tunnel whose
384    #: match is over. ``None`` / ``[]`` are the same opposite facts as
385    #: ``live_instance_ids``: ``None`` is *this platform did not say* and
386    #: reaps nothing, ``[]`` is authoritative and reaps every match
387    #: tunnel. Scoped to the node, and composed from the same live set
388    #: (dict ∪ durable claims) so a platform mid-re-adoption does not tell
389    #: a node its live match is over — the failure `live_instance_ids`
390    #: was extended to close.
391    live_match_indexes: list[int] | None = None
392    #: How long, in seconds, a challenge image may sit idle on this node
393    #: before its reaper reclaims it — the platform's DB-resolved
394    #: ``challenge_image_retention_s``. The node has no settings of its
395    #: own, so this rides the beat like ``untrusted_builds``.
396    #:
397    #: ⚠️ **Three-valued, and the third value is the whole point.**
398    #: ``None`` is *this platform did not say* — an older build, or a body
399    #: the node could not read — and the node reaps nothing. ``0`` is
400    #: authoritative and means *forever* (the shipped default), so an
401    #: operator who never sets it keeps every image; only a positive value
402    #: reaps. Defaulting to ``None`` rather than ``0`` keeps the two
403    #: opposite facts apart, but here they happen to act the same — both
404    #: leave images alone — so the danger a wrong default would cause is a
405    #: *reap*, which is why the default is the do-nothing one.
406    challenge_image_retention_s: int | None = None
407    #: How long a *finished* instance's capture directory is kept on the
408    #: node, in seconds — the platform's DB-resolved
409    #: ``instance_capture_retention_s``, pushed for
410    #: ``challenge_image_retention_s``'s reason (the node has no settings
411    #: layer). Same three values: ``None`` = did not say (reclaim
412    #: nothing), ``0`` = forever (the shipped default), positive = reap.
413    instance_capture_retention_s: int | None = None
414    #: Per-instance ceiling on a live capture's tcpdump segments, bytes —
415    #: ``instance_capture_max_bytes``. ``None`` = did not say, ``0`` = no
416    #: cap. ⚠️ Every drop it causes is recorded on disk and reported;
417    #: a cap that trims silently is the loss this setting must not be.
418    instance_capture_max_bytes: int | None = None

Body for POST /nodes/heartbeat's reply.

The beat used to answer a bare {"status": "ok"}. It carries the platform's authoritative live set for this node now, because that is the only thing that lets the node's own record store shrink — see ctfy.server.store_reconcile.retired_records() for what went wrong without it.

⚠️ None and [] are opposite facts and the difference is the whole feature. None means this platform did not say — an older build, or a response the node could not read — and authorises nothing. [] means nothing is live on you, which is authoritative and is exactly the case a leaked node is in. Defaulting the field to [] would make every old platform look like it had just retired the fleet, so the default is None and the platform always fills it.

status: str = 'ok'
live_instance_ids: list[str] | None = None
untrusted_builds: bool | None = None
live_match_indexes: list[int] | None = None
challenge_image_retention_s: int | None = None
instance_capture_retention_s: int | None = None
instance_capture_max_bytes: int | None = None
class NodeInfo(ctfy.core.models.CtfyModel):
 42class NodeInfo(CtfyModel):
 43    id: str
 44    url: str
 45    display_name: str = ""
 46    capacity: int = 0
 47    running: int = 0
 48    is_healthy: bool = True
 49    last_heartbeat: datetime | None = None
 50    # First successful registration. ``None`` only for rows that
 51    # pre-date the field; read-path code in
 52    # ``ctfy/server/routes/nodes.py`` falls back to ``last_heartbeat``
 53    # so the admin UI never shows "—" for established nodes.
 54    registered_at: datetime | None = None
 55    labels: dict[str, str] = Field(default_factory=dict)
 56    # Latest resource sample reported on heartbeat (0–100).
 57    cpu_percent: float = 0.0
 58    memory_percent: float = 0.0
 59    disk_percent: float = 0.0
 60    # Physical RAM in MiB; 0 = not reported yet. Surfaced so the admin
 61    # Nodes page can show real headroom rather than a bare percentage.
 62    memory_total_mb: float = 0.0
 63    # Owning vendor slug for a bring-your-own-node; "" = platform-shared.
 64    owner_vendor_id: str = ""
 65    # The ctfy version this node last reported. "" = a node build that
 66    # predates the field. Surfaced so the admin Nodes page can show a
 67    # skew against the platform's own version — the node is the one
 68    # boundary shipping structured data between two processes, and a
 69    # mismatch there loses fields silently rather than erroring.
 70    version: str = ""
 71    # What this node is actually running, as two facts `version` cannot
 72    # carry. ⚠️ **`version` is semver and two nodes at the same semver
 73    # are routinely different machines** — measured on this deployment,
 74    # where both workers reported `0.6.8` while one ran an image three
 75    # builds older and a corpus a month behind, and neither the nodes
 76    # page nor the skew reporter could say so. A challenge scheduled to
 77    # the stale one then failed with `Challenge not found on this node`,
 78    # intermittently, because placement is what decided which corpus
 79    # answered.
 80    #
 81    # ⚠️ `""` is *unreadable*, never a mismatch: a node build predating
 82    # the field, an image without `CTFY_GIT_COMMIT`, or a corpus that is
 83    # not a working tree. `corpus_commit_of` states the same rule for
 84    # the second one, and every reader here honours it by staying quiet.
 85    git_commit: str = ""
 86    corpus_commit: str = ""
 87    # What this node last reported it holds, as ``repository:tag ->
 88    # RepoDigest sha``, for registry-sourced images only. The platform
 89    # compares these across nodes (`core/image_consistency.py`) so a tag
 90    # resolving to different content on different workers is surfaced. Empty
 91    # for a node that predates the field or has not yet reported; the
 92    # comparison ignores an empty map exactly as it ignores an empty digest.
 93    image_digests: dict[str, str] = Field(default_factory=dict)
 94    # Whether this node's build-egress rules are what the platform asked
 95    # for (#1868). ⚠️ ``""`` is *not reported* — a node build predating
 96    # the field, or one the platform has not yet answered — and never
 97    # *in force*; the nodes page renders it as unknown. The reason is the
 98    # node's own words for a failure, empty otherwise.
 99    build_egress_status: BuildEgressStatus | Literal[""] = ""
100    build_egress_reason: str = ""
101    # Whether every helper image this node is configured with is on its
102    # daemon (#1875). ⚠️ ``""`` is *not reported* and never *present*;
103    # the reason names each missing ref and why it could not be pulled.
104    helper_images_status: HelperImagesStatus | Literal[""] = ""
105    helper_images_reason: str = ""
106    # An operator's cordon (#1900): this node takes no new instance, build,
107    # pull or patch judge, and keeps running what it already has.
108    cordoned: bool = False
109    # Whether the node carries the ``heavy_estates`` label in its closed
110    # truthy set (#1900) — large estates are placed here first. Projected
111    # by the platform (`scheduling.node_hosts_heavy_estates`) so no client
112    # re-implements which spellings of the label count: the raw label
113    # still rides ``labels``, and ``heavy_estates=false`` must read *no*.
114    heavy_estates: bool = False
115    # Where this node's ingress canary answers (#1903), as the node
116    # reported it. ``""`` advertises none.
117    ingress_canary_url: str = ""
118    # The platform's last reading of this node's ingress, dialled from
119    # outside the node (`ctfy.server.ingress_probe`). ⚠️ ``""`` is *not
120    # probed* — no canary advertised, or not reached yet — and never
121    # *ok*; ``not_applicable`` is a pool that routes no player (freeze,
122    # quarantine), projected from the one pool rule, never stored.
123    ingress_status: IngressStatus | Literal[""] = ""
124    ingress_reason: str = ""
125    # The wildcard certificate's ``notAfter`` as served to the probe;
126    # ``None`` over plain http or before a reading.
127    ingress_cert_not_after: datetime | None = None
128    ingress_checked_at: datetime | None = None

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
url: str = PydanticUndefined
display_name: str = ''
capacity: int = 0
running: int = 0
is_healthy: bool = True
last_heartbeat: datetime.datetime | None = None
registered_at: datetime.datetime | None = None
labels: dict[str, str] = PydanticUndefined
cpu_percent: float = 0.0
memory_percent: float = 0.0
disk_percent: float = 0.0
memory_total_mb: float = 0.0
owner_vendor_id: str = ''
version: str = ''
git_commit: str = ''
corpus_commit: str = ''
image_digests: dict[str, str] = PydanticUndefined
build_egress_status: Union[ctfy.core.enums.BuildEgressStatus, Literal['']] = ''
build_egress_reason: str = ''
helper_images_status: Union[ctfy.core.enums.HelperImagesStatus, Literal['']] = ''
helper_images_reason: str = ''
cordoned: bool = False
heavy_estates: bool = False
ingress_canary_url: str = ''
ingress_status: Union[ctfy.core.enums.IngressStatus, Literal['']] = ''
ingress_reason: str = ''
ingress_cert_not_after: datetime.datetime | None = None
ingress_checked_at: datetime.datetime | None = None
class NodeInviteInfo(ctfy.core.models.CtfyModel):
516class NodeInviteInfo(CtfyModel):
517    """Admin-facing summary (``GET /nodes/invites``). No token fields."""
518
519    id: str
520    created_at: datetime | None = None
521    expires_at: datetime | None = None
522    status: NodeInviteStatus
523    consumed_by_node_id: str = ""
524    owner_vendor_id: str = ""  # vendor binding, if any ("" = shared)

Admin-facing summary (GET /nodes/invites). No token fields.

id: str = PydanticUndefined
created_at: datetime.datetime | None = None
expires_at: datetime.datetime | None = None
status: Literal['active', 'consumed', 'expired'] = PydanticUndefined
consumed_by_node_id: str = ''
owner_vendor_id: str = ''
class NodePatch(ctfy.core.models.CtfyModel):
157class NodePatch(CtfyModel):
158    """Body for ``PATCH /admin/nodes/{node_id}``.
159
160    Fields are optional — absent fields keep their current value.
161    display_name is the common case; labels allow adding/replacing the
162    full dict (partial label edits need a second round-trip).
163    ``owner_vendor_id`` re-assigns (or clears, with ``""``) the node's
164    vendor binding — an admin correction path complementing the
165    invite-time binding.
166    """
167
168    display_name: str | None = None
169    labels: dict[str, str] | None = None
170    owner_vendor_id: str | None = None
171    #: ``True`` cordons the node (#1900): it takes nothing new and keeps
172    #: running what it has. ``False`` uncordons it.
173    cordoned: bool | None = None

Body for PATCH /admin/nodes/{node_id}.

Fields are optional — absent fields keep their current value. display_name is the common case; labels allow adding/replacing the full dict (partial label edits need a second round-trip). owner_vendor_id re-assigns (or clears, with "") the node's vendor binding — an admin correction path complementing the invite-time binding.

display_name: str | None = None
labels: dict[str, str] | None = None
owner_vendor_id: str | None = None
cordoned: bool | None = None
class NodeRangePush(ctfy.core.models.CtfyModel):
1069class NodeRangePush(CtfyModel):
1070    """Platform → node: build this generated range here.
1071
1072    ⚠️ **The scenario travels, not the estate.** A rendered range is
1073    70-odd files; this is a JSON object, and the node has everything it
1074    needs to compile it — the corpus is mounted there too and the
1075    compiler ships inside the corpus. Pushing files over the node API is
1076    the shape ``gauntlet_verify`` argues against, for the same reason.
1077
1078    ⚠️ **Which is why the corpus commit travels with it.** One scenario
1079    compiled against two corpus commits is two *different* estates
1080    wearing one id: a node serving different services and different
1081    milestones under the id a competition names, with nothing on any
1082    screen saying so. `ctfy.server.node_ranges.apply_range_push` refuses
1083    on a mismatch.
1084    """
1085
1086    range_id: str
1087    #: The stored knobs, exactly as the platform holds them.
1088    scenario: dict[str, Any] = Field(default_factory=dict)
1089    #: The corpus commit the platform compiled this range from. ``""``
1090    #: means the platform could not read one (no git binary, a corpus
1091    #: that is a tarball) — **not** "any commit will do" and never a
1092    #: mismatch, so the node proceeds and reports its own instead.
1093    expect_corpus_commit: str = ""

Platform → node: build this generated range here.

⚠️ The scenario travels, not the estate. A rendered range is 70-odd files; this is a JSON object, and the node has everything it needs to compile it — the corpus is mounted there too and the compiler ships inside the corpus. Pushing files over the node API is the shape gauntlet_verify argues against, for the same reason.

⚠️ Which is why the corpus commit travels with it. One scenario compiled against two corpus commits is two different estates wearing one id: a node serving different services and different milestones under the id a competition names, with nothing on any screen saying so. ctfy.server.node_ranges.apply_range_push refuses on a mismatch.

range_id: str = PydanticUndefined
scenario: dict[str, typing.Any] = PydanticUndefined
expect_corpus_commit: str = ''
class NodeRangeResult(ctfy.core.models.CtfyModel):
1096class NodeRangeResult(CtfyModel):
1097    """Node → platform: what this node did with a pushed range.
1098
1099    ⚠️ ``corpus_commit`` rather than a bare ``ok``: two nodes can both
1100    succeed and hold different estates, and only the commit says so.
1101    """
1102
1103    range_id: str = ""
1104    ok: bool = False
1105    #: What this node compiled from. Empty when it could not read one.
1106    corpus_commit: str = ""
1107    #: Why not. Empty on success — never a generic failure, because the
1108    #: repairs differ: a corpus mismatch is fixed on the node's host, a
1109    #: compiler refusal on the scenario.
1110    error: str = ""

Node → platform: what this node did with a pushed range.

⚠️ corpus_commit rather than a bare ok: two nodes can both succeed and hold different estates, and only the commit says so.

range_id: str = ''
ok: bool = False
corpus_commit: str = ''
error: str = ''
class NodeRangeSyncLine(ctfy.core.models.CtfyModel):
1310class NodeRangeSyncLine(CtfyModel):
1311    """One node's outcome in a fleet-wide range push.
1312
1313    ⚠️ Three states, not two: ``ok``, refused (``error`` from the node),
1314    and unreachable (``error`` from the transport). A boolean would
1315    render the last two identically, and their repairs are on different
1316    machines.
1317    """
1318
1319    node_id: str = ""
1320    ok: bool = False
1321    corpus_commit: str = ""
1322    error: str = ""

One node's outcome in a fleet-wide range push.

⚠️ Three states, not two: ok, refused (error from the node), and unreachable (error from the transport). A boolean would render the last two identically, and their repairs are on different machines.

node_id: str = ''
ok: bool = False
corpus_commit: str = ''
error: str = ''
class NodeRangeVerifyPush(ctfy.core.models.CtfyModel):
1209class NodeRangeVerifyPush(CtfyModel):
1210    """Platform → node: build this scenario, solve it, take it down.
1211
1212    ⚠️ **The scenario travels, not the estate** — the same decision
1213    :class:`NodeRangePush` records, and for the same reason: the corpus
1214    is mounted on the node and the compiler ships inside it. A first
1215    attempt tarred the rendered estate and posted it; that also loses
1216    ``expect_corpus_commit``, which is the whole protection.
1217
1218    ⚠️ **This is a question, not a publication.** Unlike a range *push*
1219    there is no ``range_id``: the node renders into scratch and throws it
1220    away, so a verification that fails cannot leave the estate it just
1221    disproved sitting in the catalog.
1222    """
1223
1224    #: The platform's id for this run, and the handle a cancel names.
1225    #:
1226    #: ⚠️ **Minted by the platform, not the node.** The operator presses
1227    #: cancel on the platform, which must be able to name the run before
1228    #: the node has answered anything — a node-minted id would only exist
1229    #: once the run was over.
1230    run_id: str = ""
1231    #: The studio knobs, exactly as the platform holds them.
1232    scenario: dict[str, Any] = Field(default_factory=dict)
1233    #: The corpus commit the platform previewed against. ``""`` means the
1234    #: platform could not read one — **not** "any commit will do" — so
1235    #: the node proceeds and reports its own, as ``NodeRangePush`` does.
1236    expect_corpus_commit: str = ""
1237    compose_timeout_s: int = 900
1238    solve_timeout_s: int = 600

Platform → node: build this scenario, solve it, take it down.

⚠️ The scenario travels, not the estate — the same decision NodeRangePush records, and for the same reason: the corpus is mounted on the node and the compiler ships inside it. A first attempt tarred the rendered estate and posted it; that also loses expect_corpus_commit, which is the whole protection.

⚠️ This is a question, not a publication. Unlike a range push there is no range_id: the node renders into scratch and throws it away, so a verification that fails cannot leave the estate it just disproved sitting in the catalog.

run_id: str = ''
scenario: dict[str, typing.Any] = PydanticUndefined
expect_corpus_commit: str = ''
compose_timeout_s: int = 900
solve_timeout_s: int = 600
class NodeRangeVerifyResult(ctfy.core.models.CtfyModel):
1241class NodeRangeVerifyResult(CtfyModel):
1242    """Node → platform: what happened, for the platform to judge.
1243
1244    ⚠️ **Reports; does not judge.** There is no ``passed`` here on
1245    purpose — that compares captures against the milestone count, and a
1246    node deciding it would be the second place that does, which is how
1247    the board and the counters came to disagree elsewhere in this
1248    codebase.
1249    """
1250
1251    #: What this node compiled from. Empty when it could not read one.
1252    corpus_commit: str = ""
1253    #: A refusal before any work: corpus mismatch, or a compiler that
1254    #: would not build these knobs.
1255    error: str = ""
1256    #: ``plan_verification``'s refusal — the estate is too big to build
1257    #: here. Distinct from ``error``: the scenario is fine, this node is
1258    #: not the place.
1259    refusal: str = ""
1260    services: int = 0
1261    milestones: int = 0
1262    memory_mb: int = 0
1263    up_exit_code: int | None = None
1264    #: Tail of the failing step's output; a task row is not a log store.
1265    up_output: str = ""
1266    #: The reference solution's stdout, which is what capture counting
1267    #: reads. Empty when the build never got that far.
1268    solve_stdout: str = ""
1269    solve_exit_code: int | None = None
1270    #: Which step ran out of its budget, if one did.
1271    timed_out: str = ""
1272    #: Set when the operator cancelled, naming where it landed.
1273    #:
1274    #: ⚠️ **Its own field, not an ``error``.** A cancelled run is not a
1275    #: failed one: the range is not disproved, the operator changed their
1276    #: mind, and rendering that as a failure would make the studio report
1277    #: a broken scenario every time somebody pressed the button.
1278    cancelled: str = ""
1279    #: ⚠️ Whether the node really took the estate down. The platform has
1280    #: no other way to see it, and a range left running is the failure
1281    #: this path is most likely to produce — the node tears down in its
1282    #: own ``finally`` so a platform that disconnected still leaves a
1283    #: clean host, but then nobody would know it had happened.
1284    torn_down: bool = False

Node → platform: what happened, for the platform to judge.

⚠️ Reports; does not judge. There is no passed here on purpose — that compares captures against the milestone count, and a node deciding it would be the second place that does, which is how the board and the counters came to disagree elsewhere in this codebase.

corpus_commit: str = ''
error: str = ''
refusal: str = ''
services: int = 0
milestones: int = 0
memory_mb: int = 0
up_exit_code: int | None = None
up_output: str = ''
solve_stdout: str = ''
solve_exit_code: int | None = None
timed_out: str = ''
cancelled: str = ''
torn_down: bool = False
class NodeRegister(ctfy.core.models.CtfyModel):
35class NodeRegister(CtfyModel):
36    url: str  # e.g. "http://node1:8100"
37    display_name: str  # required; operators pick a human-readable label
38    capacity: int = DEFAULT_NODE_CAPACITY
39    labels: dict[str, str] = Field(default_factory=dict)  # optional metadata

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

url: str = PydanticUndefined
display_name: str = PydanticUndefined
capacity: int = 50
labels: dict[str, str] = PydanticUndefined
class NodeRegisterResponse(ctfy.server.models.NodeInfo):
470class NodeRegisterResponse(NodeInfo):
471    """Registration response carries the node's bearer token (plaintext).
472
473    Returned exactly once, at registration. The same token is used in
474    both directions:
475
476    * **node→platform** (heartbeat, deregister) — node presents it as
477      the Bearer; platform looks the row up by comparing plaintexts.
478    * **platform→node** (start/stop/status) — platform presents the
479      same plaintext on every call; node verifies against its in-memory
480      copy.
481
482    Re-registration rotates the token. The node never persists it on
483    disk; "restart = re-register = fresh credential".
484    """
485
486    token: str = ""

Registration response carries the node's bearer token (plaintext).

Returned exactly once, at registration. The same token is used in both directions:

  • node→platform (heartbeat, deregister) — node presents it as the Bearer; platform looks the row up by comparing plaintexts.
  • platform→node (start/stop/status) — platform presents the same plaintext on every call; node verifies against its in-memory copy.

Re-registration rotates the token. The node never persists it on disk; "restart = re-register = fresh credential".

token: str = ''
class NodeRegistryTicket(ctfy.core.models.CtfyModel):
445class NodeRegistryTicket(CtfyModel):
446    """Reply for ``POST /nodes/registry-ticket``.
447
448    ⚠️ **``ticket == ""`` is a refusal, never an error**, and ``reason``
449    says which: this deployment runs no registry, it cannot say which
450    account its images live under, or — the one that is a decision — this
451    node runs player-authored code. That last node is one the operator has
452    declared may be taken over (`scheduling.node_runs`), so a credential it
453    can obtain is a credential its tenants can obtain; it gets none, and
454    its builds run with an empty registry config. The node then falls back
455    to its ambient docker config, which is exactly the behaviour before
456    this existed.
457    """
458
459    #: The password half of a docker login for ``CTFY_REGISTRY_DOMAIN``,
460    #: or ``""``.
461    ticket: str = ""
462    #: Seconds the ticket lives; ``0`` with no ticket.
463    expires_in: int = 0
464    #: The account whose repositories it may pull, for the node's log.
465    namespace: str = ""
466    #: Why there is no ticket; ``""`` when there is one.
467    reason: str = ""

Reply for POST /nodes/registry-ticket.

⚠️ ticket == "" is a refusal, never an error, and reason says which: this deployment runs no registry, it cannot say which account its images live under, or — the one that is a decision — this node runs player-authored code. That last node is one the operator has declared may be taken over (scheduling.node_runs), so a credential it can obtain is a credential its tenants can obtain; it gets none, and its builds run with an empty registry config. The node then falls back to its ambient docker config, which is exactly the behaviour before this existed.

ticket: str = ''
expires_in: int = 0
namespace: str = ''
reason: str = ''
class NodeRegistryTicketRequest(ctfy.core.models.CtfyModel):
421class NodeRegistryTicketRequest(CtfyModel):
422    """Body for ``POST /nodes/registry-ticket`` (#1820).
423
424    A node about to build or start one of the platform's challenges asks
425    for a short-lived credential to pull the platform's own images (the
426    AWD gamebox bases, the corpus's pinned mirrors) — one per operation.
427    """
428
429    #: The asking node's own id; a node token may only ask for itself.
430    id: str
431    #: How long the operation it is for may run, in seconds. Docker reads
432    #: its credential once, at the start, and presents it again for every
433    #: later pull, so the ticket must outlive the operation. Clamped by
434    #: the platform to ``NAMESPACE_PULL_TICKET_MAX_TTL_S``.
435    ttl_s: int = Field(default=1800, ge=1)
436    #: ``""`` asks for the namespace ticket a challenge build or start
437    #: uses. A repository (``<account>/ctfy-edge-dns``, no host, no tag)
438    #: asks for a pull ticket on **that repository alone**, which the
439    #: platform grants only for one of its own helper images (#1875) —
440    #: and grants to every pool, because a helper is the same image the
441    #: node already runs and is no secret of anybody's.
442    repository: str = Field(default="", max_length=255)

Body for POST /nodes/registry-ticket (#1820).

A node about to build or start one of the platform's challenges asks for a short-lived credential to pull the platform's own images (the AWD gamebox bases, the corpus's pinned mirrors) — one per operation.

id: str = PydanticUndefined
ttl_s: int = 1800
repository: str = ''
class NotificationCategoryInfo(ctfy.core.models.CtfyModel):
28class NotificationCategoryInfo(CtfyModel):
29    id: str
30    title: str
31    description: str
32    #: Account & security. Not switchable; the API refuses to store a cell.
33    forced: bool
34    #: ``transactional`` | ``marketing``. A marketing category is off
35    #: until the recipient explicitly turns it on, and a recorded
36    #: opt-out on one cannot be reverted away — so the UI must not
37    #: offer "revert to default" there, which would imply reverting
38    #: could switch it back on.
39    policy: str = "transactional"
40    channels: dict[str, NotificationChannelCell]

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
title: str = PydanticUndefined
description: str = PydanticUndefined
forced: bool = PydanticUndefined
policy: str = 'transactional'
channels: dict[str, NotificationChannelCell] = PydanticUndefined
class NotificationChannelCell(ctfy.core.models.CtfyModel):
14class NotificationChannelCell(CtfyModel):
15    """One (category, channel) cell, as the settings UI needs it.
16
17    Three values rather than one because storage is sparse: ``effective``
18    is what happens now, ``default`` is what reverting would restore, and
19    ``explicit`` says whether the user chose this or is inheriting it —
20    which is also whether a "revert" control should appear at all.
21    """
22
23    effective: bool
24    explicit: bool
25    default: bool

One (category, channel) cell, as the settings UI needs it.

Three values rather than one because storage is sparse: effective is what happens now, default is what reverting would restore, and explicit says whether the user chose this or is inheriting it — which is also whether a "revert" control should appear at all.

effective: bool = PydanticUndefined
explicit: bool = PydanticUndefined
default: bool = PydanticUndefined
class NotificationPreferenceUpdate(ctfy.core.models.CtfyModel):
47class NotificationPreferenceUpdate(CtfyModel):
48    """PATCH body for one cell."""
49
50    category: str
51    channel: str = "email"
52    enabled: bool

PATCH body for one cell.

category: str = PydanticUndefined
channel: str = 'email'
enabled: bool = PydanticUndefined
class NotificationPreferencesResponse(ctfy.core.models.CtfyModel):
43class NotificationPreferencesResponse(CtfyModel):
44    categories: list[NotificationCategoryInfo]

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

categories: list[NotificationCategoryInfo] = PydanticUndefined
class OAuthProviderInfo(ctfy.core.models.CtfyModel):
27class OAuthProviderInfo(CtfyModel):
28    name: OAuthProviderName
29    enabled: bool
30    authorize_path: str  # relative, e.g. "/api/v1/auth/login/github"
31    # Operator-supplied button label. Empty for the built-in providers,
32    # whose names the client already translates ("Sign in with GitHub").
33    # A generic OIDC provider has no name the client could know — the
34    # only useful label is the one the operator chose ("清华大学统一身份
35    # 认证"), so it travels on the wire rather than living in a bundle.
36    display_name: str = ""
37    # A link-only provider (CARSI) is bound from a signed-in account and is
38    # never a way in. Only ``ProvidersResponse.link_providers`` lists one;
39    # ``providers`` (the sign-in buttons) never does.
40    link_only: bool = False
41    # Where the provider *is* usable when this host does not carry it: the
42    # origin of the host whose credentials exist. ``""`` = usable here (or
43    # nowhere). A browser session is per origin, so the primary can only
44    # point there, not bind on its behalf.
45    available_at: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

name: Literal['github', 'google', 'oidc', 'carsi'] = PydanticUndefined
enabled: bool = PydanticUndefined
authorize_path: str = PydanticUndefined
display_name: str = ''
available_at: str = ''
class OnboardingIntentRequest(ctfy.core.models.CtfyModel):
297class OnboardingIntentRequest(CtfyModel):
298    """Body for ``POST /me/onboarding`` — what this account came to do.
299
300    ⚠️ **Its own request model, deliberately not a field on
301    :class:`ProfilePatchRequest`.** A profile is what a visitor reads
302    off somebody's page; this is an answer given to the platform, and
303    the two have different audiences, different write paths and
304    different lifetimes. Folding it in would also put it inside the
305    profile-visibility machinery, where every key is a thing somebody
306    can choose to publish.
307
308    ⚠️ The type is the enum, so an unrecognised value is a **422 and no
309    write**. The welcome wizard branches on this string; a stored
310    ``"agent"`` — a plausible typo for ``byo`` — matches no branch and
311    renders a blank card where the guidance was, with nothing reporting
312    it. ``""`` is accepted so a client can put an account back to
313    "never asked".
314    """
315
316    intent: OnboardingIntent

Body for POST /me/onboarding — what this account came to do.

⚠️ Its own request model, deliberately not a field on ProfilePatchRequest. A profile is what a visitor reads off somebody's page; this is an answer given to the platform, and the two have different audiences, different write paths and different lifetimes. Folding it in would also put it inside the profile-visibility machinery, where every key is a thing somebody can choose to publish.

⚠️ The type is the enum, so an unrecognised value is a 422 and no write. The welcome wizard branches on this string; a stored "agent" — a plausible typo for byo — matches no branch and renders a blank card where the guidance was, with nothing reporting it. "" is accepted so a client can put an account back to "never asked".

intent: Literal['', 'human', 'connect', 'byo', 'author', 'skipped'] = PydanticUndefined
class PasswordAuthInfo(ctfy.core.models.CtfyModel):
48class PasswordAuthInfo(CtfyModel):
49    """Gating flag for the email+password sign-in form on the login page."""
50
51    enabled: bool = False

Gating flag for the email+password sign-in form on the login page.

enabled: bool = False
class PatchStepInfo(ctfy.core.models.CtfyModel):
1550class PatchStepInfo(CtfyModel):
1551    """One stage of a verification, as the node judged it — organiser-only.
1552
1553    The node runs build → boot → each checker action → reference exploit
1554    and returns a result per stage; the platform used to keep only the
1555    single player-facing sentence, so an `inconclusive` was three
1556    different repairs at once (issue #1692). These are on the admin
1557    detail read alone: a checker's ``detail`` can describe the
1558    challenge's internals, which the defender must not see.
1559    """
1560
1561    name: str = ""
1562    ok: bool = False
1563    detail: str = ""

One stage of a verification, as the node judged it — organiser-only.

The node runs build → boot → each checker action → reference exploit and returns a result per stage; the platform used to keep only the single player-facing sentence, so an inconclusive was three different repairs at once (issue #1692). These are on the admin detail read alone: a checker's detail can describe the challenge's internals, which the defender must not see.

name: str = ''
ok: bool = False
detail: str = ''
class PatchSubmissionCreate(ctfy.core.models.CtfyModel):
1415class PatchSubmissionCreate(CtfyModel):
1416    """An AWD+ defence submission, addressed one of two ways.
1417
1418    ``instance_id`` is the handle when a box is running, exactly as on
1419    :class:`SubmissionCreate`: the instance row carries the challenge,
1420    the team and the competition, so the caller repeats none of them.
1421
1422    ⚠️ **But a defence submission does not need a box, and requiring one
1423    made the format's natural order unplayable.** Judging builds from
1424    the pristine tree plus the submitted bytes and never touches the
1425    instance, ``GET /challenges/{id}/defence/source`` is already
1426    challenge-scoped for exactly this reason, and upsolve, virtual
1427    participation and a post-event fix all mean patching with nothing
1428    running. So ``challenge_id`` (plus ``competition_id``, which scopes
1429    which team is credited) addresses it directly.
1430
1431    Naming neither is refused rather than guessed at: falling back to
1432    "the caller's only instance" would silently judge a challenge they
1433    did not name. Naming an instance *and* a different challenge is
1434    refused for the same reason.
1435
1436    **This is not a weaker authorisation than the instance form.** There
1437    is no team on the wire either way — the credited team is resolved
1438    from the caller's own membership, as ``GET /patches`` already does —
1439    so the challenge form has no id to spoof at all.
1440    """
1441
1442    instance_id: str = ""
1443    #: The challenge to defend, when no box is running. Ignored (but
1444    #: cross-checked) when ``instance_id`` is given.
1445    challenge_id: str = ""
1446    #: Which competition credits the patch. Empty means the caller's
1447    #: unscoped team, the same convention ``GET /patches`` follows.
1448    competition_id: str = ""
1449    #: Where the patched bytes come from.
1450    #:
1451    #: ``upload`` carries them in ``files``. ``live`` reads them out of
1452    #: the player's own running box — they SSH in, edit, and submit with
1453    #: no payload at all, which is how an AWD player actually works.
1454    #:
1455    #: Explicit rather than inferred from an empty ``files``: an empty
1456    #: map is also what a client bug produces, and silently switching
1457    #: channel on a bug would grade something the player never meant to
1458    #: send.
1459    source: Literal["upload", "live"] = "upload"
1460    #: ``{challenge-relative path: base64(bytes)}``. Base64 because a
1461    #: patch target may legitimately be binary, and JSON has no way to
1462    #: carry those bytes intact otherwise. Must be empty when
1463    #: ``source="live"``.
1464    files: dict[str, str] = Field(default_factory=dict)

An AWD+ defence submission, addressed one of two ways.

instance_id is the handle when a box is running, exactly as on SubmissionCreate: the instance row carries the challenge, the team and the competition, so the caller repeats none of them.

⚠️ But a defence submission does not need a box, and requiring one made the format's natural order unplayable. Judging builds from the pristine tree plus the submitted bytes and never touches the instance, GET /challenges/{id}/defence/source is already challenge-scoped for exactly this reason, and upsolve, virtual participation and a post-event fix all mean patching with nothing running. So challenge_id (plus competition_id, which scopes which team is credited) addresses it directly.

Naming neither is refused rather than guessed at: falling back to "the caller's only instance" would silently judge a challenge they did not name. Naming an instance and a different challenge is refused for the same reason.

This is not a weaker authorisation than the instance form. There is no team on the wire either way — the credited team is resolved from the caller's own membership, as GET /patches already does — so the challenge form has no id to spoof at all.

instance_id: str = ''
challenge_id: str = ''
competition_id: str = ''
source: Literal['upload', 'live'] = 'upload'
files: dict[str, str] = PydanticUndefined
class PatchSubmissionInfo(ctfy.core.models.CtfyModel):
1501class PatchSubmissionInfo(CtfyModel):
1502    """A defence submission's status — deliberately without its code.
1503
1504    The stored row keeps the submitted bytes so a verdict can be
1505    re-derived after a checker fix, but a *status* view is polled, and
1506    shipping every patch body on every poll is bulk nobody asked for.
1507    """
1508
1509    id: str
1510    challenge_id: str
1511    competition_id: str
1512    submitted_at: datetime | None = None
1513    #: The paths this submission replaced.
1514    files: list[str] = Field(default_factory=list)
1515    #: ⚠️ The stored column is already `PatchSubmissionStatus`; this
1516    #: copy was a bare `str`, so the client asserted nothing about the
1517    #: value it branches on.
1518    status: PatchSubmissionStatus = "pending"
1519    #: Empty until judged, and empty forever on a submission the
1520    #: platform failed to judge — our outage is not the player's loss.
1521    #: ⚠️ Bare `str` until now, and `DefenceCard` compares it against
1522    #: literals — measured, `row.verdict === "not_fixxed"` compiled with
1523    #: **zero** `tsc` errors. Typed, that typo is a build failure.
1524    verdict: Verdict | Literal[""] = ""
1525    detail: str = ""
1526    judged_at: datetime | None = None

A defence submission's status — deliberately without its code.

The stored row keeps the submitted bytes so a verdict can be re-derived after a checker fix, but a status view is polled, and shipping every patch body on every poll is bulk nobody asked for.

id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
submitted_at: datetime.datetime | None = None
files: list[str] = PydanticUndefined
status: Literal['pending', 'running', 'judged', 'errored'] = 'pending'
verdict: Union[ctfy.core.enums.Verdict, Literal['']] = ''
detail: str = ''
judged_at: datetime.datetime | None = None
class PlatformSettingInfo(ctfy.core.models.CtfyModel):
469class PlatformSettingInfo(CtfyModel):
470    """One row from ``GET /admin/platform-settings``.
471
472    ``value`` is the live resolved value (DB > env > default); the
473    other two fields show what env and the built-in default would
474    resolve to in isolation, so the UI can render a tooltip
475    explaining what a ``DELETE`` would land on. ``source`` is the
476    tier the current value came from.
477    """
478
479    key: str = ""
480    type: str = ""
481    description: str = ""
482    value: Any = None
483    source: SettingSource | Literal[""] = ""
484    env_value: Any = None
485    default_value: Any = None
486    #: Whether the resolved ``value`` is actually being applied.
487    #:
488    #: ⚠️ **A setting can resolve ``True`` and do nothing**, and this
489    #: page is where an operator forms the belief that it is running.
490    #: ``email_verification_required`` is the live case:
491    #: ``enforcement_enabled`` is ``wanted AND the deployment can send
492    #: mail``, because the only self-service way to satisfy the gate is
493    #: a mailed code — so on a deployment with no mail provider the row
494    #: reads ``true`` while every unproved account writes freely. That
495    #: is this repository's second failure shape at operator scale: a
496    #: thing that reports healthy while doing nothing, stably and
497    #: invisibly.
498    #:
499    #: ⚠️ Defaults **True** — the withheld value must never be a new
500    #: claim, and "not in force" is a claim. An older server that omits
501    #: the field says nothing rather than accusing every setting of
502    #: being inert.
503    in_force: bool = True
504    #: Why the value is not in force, as a stable code the admin page
505    #: translates; ``""`` when it is. A code rather than prose because
506    #: the reason has to be readable in both locales, and rendering it
507    #: server-side would put copy on a route that carries none.
508    inert_reason: str = ""

One row from GET /admin/platform-settings.

value is the live resolved value (DB > env > default); the other two fields show what env and the built-in default would resolve to in isolation, so the UI can render a tooltip explaining what a DELETE would land on. source is the tier the current value came from.

key: str = ''
type: str = ''
description: str = ''
value: Any = None
source: Union[Literal['db', 'env', 'default'], Literal['']] = ''
env_value: Any = None
default_value: Any = None
in_force: bool = True
inert_reason: str = ''
class PlatformSettingPatch(ctfy.core.models.CtfyModel):
511class PlatformSettingPatch(CtfyModel):
512    """Body of ``PATCH /admin/platform-settings/{key}``.
513
514    ``value`` is JSON-typed; the resolver's per-key validator decides
515    whether it's accepted.
516    """
517
518    value: Any = None

Body of PATCH /admin/platform-settings/{key}.

value is JSON-typed; the resolver's per-key validator decides whether it's accepted.

value: Any = None
class PlayerShellCreate(ctfy.core.models.CtfyModel):
637class PlayerShellCreate(CtfyModel):
638    """Ask for a shell into your own AWD+ box.
639
640    Note what is *absent*: no container. The admin shell takes one
641    because a super-admin legitimately picks among an instance's
642    containers; accepting one here would hand the caller the choice the
643    endpoint exists to deny them. The platform resolves the target from
644    the challenge's own ``patch.live.service``.
645    """
646
647    #: ``bash`` or ``sh``. A tiny allow-list rather than free text —
648    #: the value becomes the exec'd command on the node.
649    shell: str = "bash"

Ask for a shell into your own AWD+ box.

Note what is absent: no container. The admin shell takes one because a super-admin legitimately picks among an instance's containers; accepting one here would hand the caller the choice the endpoint exists to deny them. The platform resolves the target from the challenge's own patch.live.service.

shell: str = 'bash'
class PlayerShellTicket(ctfy.core.models.CtfyModel):
721class PlayerShellTicket(CtfyModel):
722    """A single-use ticket for one shell session.
723
724    Two-step because a browser cannot attach an ``Authorization`` header
725    to a WebSocket upgrade. Worth keeping for terminal clients too: it
726    means the long-lived bearer never rides in a URL.
727    """
728
729    session_id: str
730    #: Burns on first WebSocket use and is never returned again.
731    session_token: str
732    #: Platform-relative; the client makes it absolute against its own
733    #: base URL, since only the client knows the scheme it reached us on.
734    ws_url: str
735    #: The resolved compose container. Empty for the browser, which
736    #: never needs it; the bastion does, because it opens the node
737    #: WebSocket itself rather than going through the platform page.
738    container: str = ""
739    #: Seconds the unburned ticket stays valid.
740    expires_in: int
741    #: The compose service the session will land in. Echoed back so a
742    #: player knows which container they are in without reading the
743    #: challenge's metadata — the platform resolved it, so it says so.
744    service: str

A single-use ticket for one shell session.

Two-step because a browser cannot attach an Authorization header to a WebSocket upgrade. Worth keeping for terminal clients too: it means the long-lived bearer never rides in a URL.

session_id: str = PydanticUndefined
session_token: str = PydanticUndefined
ws_url: str = PydanticUndefined
container: str = ''
expires_in: int = PydanticUndefined
service: str = PydanticUndefined
class ProfilePatchRequest(ctfy.core.models.CtfyModel):
319class ProfilePatchRequest(CtfyModel):
320    """Body for ``PATCH /me/profile``. Every field is optional so the
321    UI can send only the diff; the route only writes the fields that
322    were present in the JSON. ``None`` means "clear this field"."""
323
324    # ``Field(default=...)`` with a non-None sentinel is awkward in
325    # pydantic; we instead declare each field as Optional + default-None
326    # and rely on the request's raw dict (via ``model_fields_set``) to
327    # know which keys were actually present.
328    bio: str | None = Field(default=None, max_length=280)
329    country: str | None = None
330    website_url: WebUrl | None = Field(default=None, max_length=200)
331    timezone: str | None = Field(default=None, max_length=80)
332    # Values are *handles*, not URLs — ``{"github": "octocat"}``. The
333    # client owns href construction from a fixed per-provider template
334    # (``https://github.com/<handle>``), and the two providers that do
335    # accept a free-form value run it through ``normalizeUrl`` /
336    # ``mastodonUrl``, both of which only ever emit ``https://…``. So a
337    # scheme constraint here would be the wrong shape: it would reject
338    # every legitimate handle while defending against nothing. What was
339    # actually missing is a size bound.
340    social_links: dict[str, str] | None = None
341
342    @field_validator("social_links")
343    @classmethod
344    def _bound_social_links(cls, v: dict[str, str] | None) -> dict[str, str] | None:
345        if v is None:
346            return None
347        if len(v) > 10:
348            raise ValueError("social_links may contain at most 10 entries")
349        if any(len(handle) > 200 for handle in v.values()):
350            raise ValueError("each social link must be at most 200 characters")
351        return v

Body for PATCH /me/profile. Every field is optional so the UI can send only the diff; the route only writes the fields that were present in the JSON. None means "clear this field".

bio: str | None = None
country: str | None = None
website_url: Optional[Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x72ea04d2a980>)]] = None
timezone: str | None = None
class ProfileStats(ctfy.core.models.CtfyModel):
478class ProfileStats(CtfyModel):
479    """Aggregate per-team analytics surfaced on the public profile.
480
481    Every section is independently visibility-gated; non-owner viewers
482    see ``[]`` for a section the team has marked private. Owner and
483    admin always see the full payload."""
484
485    calendar: list[CalendarBucket] = Field(default_factory=list)
486    by_tag: list[TagStat] = Field(default_factory=list)
487    by_difficulty: list[DifficultyStat] = Field(default_factory=list)
488    solve_trend: list[TrendPoint] = Field(default_factory=list)

Aggregate per-team analytics surfaced on the public profile.

Every section is independently visibility-gated; non-owner viewers see [] for a section the team has marked private. Owner and admin always see the full payload.

calendar: list[CalendarBucket] = PydanticUndefined
by_tag: list[TagStat] = PydanticUndefined
by_difficulty: list[DifficultyStat] = PydanticUndefined
solve_trend: list[TrendPoint] = PydanticUndefined
class ProvidersResponse(ctfy.core.models.CtfyModel):
66class ProvidersResponse(CtfyModel):
67    # The sign-in buttons: link-only providers are never here.
68    providers: list[OAuthProviderInfo] = Field(default_factory=list)
69    # What an account may bind -- the same table as ``providers``, read
70    # without the link-only filter (and with a link-only provider this host
71    # does not carry shown as ``available_at`` the host that does).
72    link_providers: list[OAuthProviderInfo] = Field(default_factory=list)
73    password_auth: PasswordAuthInfo = Field(default_factory=PasswordAuthInfo)
74    email_code_auth: EmailCodeAuthInfo = Field(default_factory=EmailCodeAuthInfo)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

providers: list[OAuthProviderInfo] = PydanticUndefined
password_auth: PasswordAuthInfo = PydanticUndefined
email_code_auth: EmailCodeAuthInfo = PydanticUndefined
class PublicTranscriptBlock(ctfy.core.models.CtfyModel):
1444class PublicTranscriptBlock(CtfyModel):
1445    """One block of an agent's own turn, scrubbed (B2).
1446
1447    ⚠️ **Only three kinds ever reach the wire** — ``text`` (assistant
1448    prose), ``thinking`` (its reasoning), ``tool_use`` (a call it made).
1449    The server's whitelist (``core.agent_transcript``) never emits a
1450    system prompt, a tool *result* or anything else, so a fourth kind
1451    here would be a bug, not a new carrier. ``name`` is the tool for
1452    ``tool_use`` and empty otherwise; ``text`` is the prose, or the
1453    scrubbed ``key=value`` arguments for ``tool_use``.
1454    """
1455
1456    kind: str = ""
1457    text: str = ""
1458    name: str = ""

One block of an agent's own turn, scrubbed (B2).

⚠️ Only three kinds ever reach the wire — text (assistant prose), thinking (its reasoning), tool_use (a call it made). The server's whitelist (core.agent_transcript) never emits a system prompt, a tool result or anything else, so a fourth kind here would be a bug, not a new carrier. name is the tool for tool_use and empty otherwise; text is the prose, or the scrubbed key=value arguments for tool_use.

kind: str = ''
text: str = ''
name: str = ''
class PublicTranscriptResponse(ctfy.core.models.CtfyModel):
1467class PublicTranscriptResponse(CtfyModel):
1468    """An entered agent's public, scrubbed conversation (B2).
1469
1470    ⚠️ **The agent's own turns only.** ``had_system`` / ``had_tool_result``
1471    say a system prompt / a tool result was present and **withheld**, so
1472    the reader sees *that* content was removed rather than a gap that
1473    reads as the agent having said nothing. The bodies themselves never
1474    travel — see ``core.agent_transcript``'s whitelist.
1475    """
1476
1477    call_id: str = ""
1478    team_id: str = ""
1479    team_name: str = ""
1480    model: str = ""
1481    at_ts: float = 0.0
1482    #: A body to read at all — retention may have reclaimed it.
1483    available: bool = False
1484    had_system: bool = False
1485    had_tool_result: bool = False
1486    turns: list[PublicTranscriptTurn] = Field(default_factory=list)

An entered agent's public, scrubbed conversation (B2).

⚠️ The agent's own turns only. had_system / had_tool_result say a system prompt / a tool result was present and withheld, so the reader sees that content was removed rather than a gap that reads as the agent having said nothing. The bodies themselves never travel — see core.agent_transcript's whitelist.

call_id: str = ''
team_id: str = ''
team_name: str = ''
model: str = ''
at_ts: float = 0.0
available: bool = False
had_system: bool = False
had_tool_result: bool = False
turns: list[PublicTranscriptTurn] = PydanticUndefined
class PublicTranscriptTurn(ctfy.core.models.CtfyModel):
1461class PublicTranscriptTurn(CtfyModel):
1462    """One assistant turn — a run of blocks the agent produced together."""
1463
1464    blocks: list[PublicTranscriptBlock] = Field(default_factory=list)

One assistant turn — a run of blocks the agent produced together.

blocks: list[PublicTranscriptBlock] = PydanticUndefined
class QaChallengeView(ctfy.core.models.CtfyModel):
970class QaChallengeView(CtfyModel):
971    """One QA challenge as seen by a team on the quiz surface.
972
973    Carries the full question / choices / description data alongside
974    per-team solve and attempt state, so the frontend can render the
975    quiz list with progress badges in a single round-trip.
976    """
977
978    id: str
979    name: str
980    # Provenance bucket prefix — ``SECQA`` / ``MMLU-CS`` / ``CTI-MCQ``
981    # / etc — derived from the challenge id (everything before the
982    # trailing numeric suffix). Lets the frontend group / filter
983    # without splitting the id again client-side.
984    bucket: str
985    description: str
986    difficulty: str = ""
987    tags: list[str] = Field(default_factory=list)
988    questions: list[QuestionPublicInfo] = Field(default_factory=list)
989    # Question ids the calling team has already captured.
990    solved_question_ids: list[str] = Field(default_factory=list)
991    # Question ids the team has submitted at least one wrong answer
992    # against but never solved. Drives the "Try again" amber badge.
993    attempted_wrong_question_ids: list[str] = Field(default_factory=list)

One QA challenge as seen by a team on the quiz surface.

Carries the full question / choices / description data alongside per-team solve and attempt state, so the frontend can render the quiz list with progress badges in a single round-trip.

id: str = PydanticUndefined
name: str = PydanticUndefined
bucket: str = PydanticUndefined
description: str = PydanticUndefined
difficulty: str = ''
tags: list[str] = PydanticUndefined
questions: list[QuestionPublicInfo] = PydanticUndefined
solved_question_ids: list[str] = PydanticUndefined
attempted_wrong_question_ids: list[str] = PydanticUndefined
class QaSubmissionCreate(ctfy.core.models.CtfyModel):
953class QaSubmissionCreate(CtfyModel):
954    """Request body for ``POST /qa/submissions`` — the instance-free
955    submission path for pure question-answer challenges.
956
957    No ``instance_id``: QA challenges have no Docker infra to launch,
958    so there's no per-team instance row to read context off. The
959    caller passes ``challenge_id`` + ``competition_id`` explicitly,
960    and the server resolves the submitter's team for the named
961    competition via the standard auth helper.
962    """
963
964    challenge_id: str
965    competition_id: str
966    question_id: str = "answer"
967    answer: str | list[str]

Request body for POST /qa/submissions — the instance-free submission path for pure question-answer challenges.

No instance_id: QA challenges have no Docker infra to launch, so there's no per-team instance row to read context off. The caller passes challenge_id + competition_id explicitly, and the server resolves the submitter's team for the named competition via the standard auth helper.

challenge_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
question_id: str = 'answer'
answer: str | list[str] = PydanticUndefined
class QuestionAttemptResetInfo(ctfy.core.models.CtfyModel):
855class QuestionAttemptResetInfo(CtfyModel):
856    """Response from the admin reset endpoint. Echoes the new baseline
857    row so the UI can update its local state without re-fetching the
858    instance.
859    """
860
861    team_id: str
862    challenge_id: str
863    question_id: str
864    reset_at: datetime
865    reset_by_user_id: str
866    reason: str

Response from the admin reset endpoint. Echoes the new baseline row so the UI can update its local state without re-fetching the instance.

team_id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
question_id: str = PydanticUndefined
reset_at: datetime.datetime = PydanticUndefined
reset_by_user_id: str = PydanticUndefined
reason: str = PydanticUndefined
class QuestionAttemptResetRequest(ctfy.core.models.CtfyModel):
544class QuestionAttemptResetRequest(CtfyModel):
545    """Request body for ``POST /admin/teams/.../reset-attempts``.
546
547    ``reason`` is free-form audit metadata recorded onto the activity
548    log row; capped at 500 chars so it round-trips through the SSE
549    payload without bloating the wire frame.
550    """
551
552    reason: str = Field(default="", max_length=500)

Request body for POST /admin/teams/.../reset-attempts.

reason is free-form audit metadata recorded onto the activity log row; capped at 500 chars so it round-trips through the SSE payload without bloating the wire frame.

reason: str = ''
class QuestionCoverageRow(ctfy.core.models.CtfyModel):
1532class QuestionCoverageRow(CtfyModel):
1533    """Where one declared question ended one run.
1534
1535    ⚠️ The prompt travels because the reader is deciding what to do next,
1536    and ``dcsync_domain_admin`` is a slug. A table of slugs is a table
1537    nobody triages — ``AdminHarnessRow``'s finding, one queue over.
1538    """
1539
1540    question_id: str = ""
1541    prompt: str = ""
1542    #: ⚠️ **Required, no default.** The three members are three different
1543    #: repairs, so a fourth value meaning *this server did not say* would
1544    #: be a state with no reader and no remedy — ``AdminTimeseries.metric``'s
1545    #: rule. One producer writes this and it always knows.
1546    state: CoverageState
1547    #: What the Reporting grade said about this question — present only
1548    #: where this run captured it and the report has been graded.
1549    #:
1550    #: ⚠️ **A second axis, never a fourth ``CoverageState``.** Coverage
1551    #: says how much of the range was reached and this says whether the
1552    #: document admits it; they move independently, which is the whole
1553    #: premise of ``#1582``'s two numbers. Folding *found and never
1554    #: described* into the coverage enum rebuilds the one blended figure
1555    #: that made the hundred unreadable.
1556    #:
1557    #: ⚠️ **``None`` carries three absences and that is deliberate**: the
1558    #: run did not capture this, the report was never graded, or the
1559    #: stored verdict is a string this server does not recognise. The
1560    #: first is readable from ``state``; the second from ``graded`` on
1561    #: the envelope; the third is a platform fault that must not be
1562    #: invented into a verdict either way.
1563    verdict: Verdict | None = None

Where one declared question ended one run.

⚠️ The prompt travels because the reader is deciding what to do next, and dcsync_domain_admin is a slug. A table of slugs is a table nobody triages — AdminHarnessRow's finding, one queue over.

question_id: str = ''
prompt: str = ''
state: ctfy.core.enums.CoverageState = PydanticUndefined
verdict: Optional[Literal['accurate', 'inaccurate', 'not_mentioned']] = None
class QuestionPublicInfo(ctfy.core.models.CtfyModel):
32class QuestionPublicInfo(CtfyModel):
33    """One question, as exposed to agents and the web UI.
34
35    Carries the prompt + mode + ``choices:`` enum (when present) so the
36    UI can render a radio/checkbox/free-text input without an extra
37    round-trip to fetch the spec. ``answer`` is NEVER projected — the
38    groundtruth lives on the platform either as metadata.yaml or as
39    a per-instance mint.
40    """
41
42    id: str
43    prompt: str
44    # Grading mode: ``dynamic`` | ``static`` | ``single_select`` |
45    # ``multi_select``. ``None`` only on malformed metadata (which the
46    # audit catches before deploy).
47    mode: str | None = None
48    # Closed-list options for ``single_select`` / ``multi_select``;
49    # ``None`` for free-form modes.
50    choices: list[str] | None = None
51    # Question ids that must be answered correctly first. Both public
52    # surfaces gate on this list: the *instance* projection
53    # (``instances._to_info``) redacts a locked prompt, and the *catalog*
54    # projection (``projections.question_infos``) omits the entry
55    # entirely until it unlocks.
56    # ⚠️ This comment used to claim that gating while the catalog
57    # published every gated prompt, id, ``requires`` edge and the total
58    # count — a false statement about disclosure, written on the model
59    # that was doing the disclosing. Recorded rather than quietly edited
60    # because it read as a guarantee and was believed.
61    requires: list[str] = Field(default_factory=list)

One question, as exposed to agents and the web UI.

Carries the prompt + mode + choices: enum (when present) so the UI can render a radio/checkbox/free-text input without an extra round-trip to fetch the spec. answer is NEVER projected — the groundtruth lives on the platform either as metadata.yaml or as a per-instance mint.

id: str = PydanticUndefined
prompt: str = PydanticUndefined
mode: str | None = None
choices: list[str] | None = None
requires: list[str] = PydanticUndefined
class RangeProgress(ctfy.core.models.CtfyModel):
212class RangeProgress(CtfyModel):
213    """Every team's position on one range of one competition."""
214
215    competition_id: str = ""
216    challenge_id: str = ""
217    rows: list[RangeProgressRow] = Field(default_factory=list)
218    #: How many milestones the range declares — the denominator for
219    #: ``reached``. ⚠️ ``0`` means the catalog could not resolve the
220    #: challenge, never "no milestones": a reader must render no fraction
221    #: at all rather than dividing by it, the rule
222    #: ``AdminSolveMatrixChallenge.milestones`` already records.
223    milestone_count: int = 0
224    #: ⚠️ ``False`` when the observation read hit its cap. Every tier is
225    #: *positive* evidence, so a truncated read can only make hosts look
226    #: less reached than they were — the direction nobody reports, hence
227    #: a published flag rather than a silent bound.
228    evidence_complete: bool = True

Every team's position on one range of one competition.

competition_id: str = ''
challenge_id: str = ''
rows: list[RangeProgressRow] = PydanticUndefined
milestone_count: int = 0
evidence_complete: bool = True
class RangeProgressRow(ctfy.core.models.CtfyModel):
145class RangeProgressRow(CtfyModel):
146    """Where one team is on one range, for the people running it.
147
148    The sibling :class:`ChallengeBlueprint` says what the range *is*;
149    this says where a team got to on it, and the two are fetched
150    separately on purpose — the blueprint is a property of the challenge
151    and cacheable, this changes on every save.
152
153    ⚠️ **No network depth here.** "Which layer" wants a BFS from the
154    published segment and that walk has one home already
155    (``frontend/src/lib/segmentDepth.ts``); the client derives the layer
156    from ``compromised`` against the blueprint it is already holding.
157    ``per_segment`` is the raw material for that and needs no ordering
158    of the segments to be true.
159
160    ⚠️ **No groundtruth**, exactly as on the blueprint: this names which
161    milestone *ids* a team holds, never what any of them is worth or
162    what the minted value was.
163    """
164
165    team_id: str = ""
166    team_name: str = ""
167    #: See :attr:`ScoreboardEntry.anonymous`. An organiser of this
168    #: competition sees through it; anybody else reads the alias.
169    anonymous: bool = False
170    #: ⚠️ Cleared for a withheld row by the same projection that clears
171    #: the name — ``anonymity._board_fields`` argues it: a surface that
172    #: withheld the name and kept the face would be decorative.
173    avatar_url: str = ""
174    reached: list[str] = Field(default_factory=list)
175    #: Reached milestones with at least one unreached successor — where
176    #: this team is stopped. ⚠️ Empty is *both* "nothing reached" and
177    #: "every reached branch is finished"; ``reached`` is what separates
178    #: them, which is why a reader must not key on this alone.
179    frontier: list[str] = Field(default_factory=list)
180    #: Unreached milestones whose prerequisites are all satisfied — what
181    #: this team could take next, including the origins of paths they
182    #: have never touched.
183    available: list[str] = Field(default_factory=list)
184    #: Estate hosts carrying a reached milestone. **Observed.**
185    compromised: list[str] = Field(default_factory=list)
186    #: Estate hosts sharing a segment with a compromised one.
187    #: ⚠️ **Inferred, not observed** — the platform does not know where a
188    #: team's shell is. A renderer that paints this like ``compromised``
189    #: is asserting a measurement nobody took.
190    reachable: list[str] = Field(default_factory=list)
191    #: ``segment -> [reached, total]`` over milestones carried there.
192    per_segment: dict[str, list[int]] = Field(default_factory=dict)
193    #: Reached milestones **no estate segment carries** — external recon
194    #: and OSINT. ⚠️ Published because it cannot be derived from the two
195    #: fields beside it: ``reached`` is a set and ``per_segment`` is a
196    #: multiset over ``(milestone, segment)`` pairs, so the subtraction
197    #: that suggests itself type-checks and means nothing —
198    #: ``RangeProgress.off_segment`` carries the measurement.
199    off_segment: list[str] = Field(default_factory=list)
200    #: ``host -> EvidenceTier`` — how strongly this team is known to have
201    #: reached each host of the estate. ⚠️ **One mark per host, never a
202    #: stack**: the tiers are strengths, not stages a team walked
203    #: through, and `ctfy.core.evidence` argues why a cumulative
204    #: rendering asserts a history nobody recorded.
205    #:
206    #: ⚠️ An empty map means the blueprint could not be read, never
207    #: "nothing was observed" — every host of a readable estate has an
208    #: entry, including the ones at ``none``.
209    evidence: dict[str, EvidenceTier] = Field(default_factory=dict)

Where one team is on one range, for the people running it.

The sibling ChallengeBlueprint says what the range is; this says where a team got to on it, and the two are fetched separately on purpose — the blueprint is a property of the challenge and cacheable, this changes on every save.

⚠️ No network depth here. "Which layer" wants a BFS from the published segment and that walk has one home already (frontend/src/lib/segmentDepth.ts); the client derives the layer from compromised against the blueprint it is already holding. per_segment is the raw material for that and needs no ordering of the segments to be true.

⚠️ No groundtruth, exactly as on the blueprint: this names which milestone ids a team holds, never what any of them is worth or what the minted value was.

team_id: str = ''
team_name: str = ''
anonymous: bool = False
avatar_url: str = ''
reached: list[str] = PydanticUndefined
frontier: list[str] = PydanticUndefined
available: list[str] = PydanticUndefined
compromised: list[str] = PydanticUndefined
reachable: list[str] = PydanticUndefined
per_segment: dict[str, list[int]] = PydanticUndefined
off_segment: list[str] = PydanticUndefined
evidence: dict[str, ctfy.core.evidence.EvidenceTier] = PydanticUndefined
class RangeReplay(ctfy.core.models.CtfyModel):
276class RangeReplay(CtfyModel):
277    """One range wound back, as a fixed number of frames.
278
279    ⚠️ **Computed server-side and shipped once**, which is a decision
280    with a named alternative: a client that tallied ``per_segment``
281    itself at time T would be a second implementation of the
282    milestone→segment map, and one round trip per slider step would put
283    a fetch on every drag. So the count of frames is the knob, and the
284    payload is bounded by it rather than by how long the event ran.
285
286    ⚠️ **The clock is ``solved_at``**, so the last frame equals the
287    static ``/progress`` picture element for element — the property the
288    feature rests on — and a capture taken before the window opened is
289    held in frame 0 rather than appearing mid-run.
290    ``ctfy.core.range_replay`` carries the argument.
291    """
292
293    competition_id: str = ""
294    challenge_id: str = ""
295    #: Ascending in time, both ends of the span included.
296    #:
297    #: ⚠️ **Empty means the range has no time axis** — nothing was
298    #: captured *and* the competition declares no window — never "the
299    #: replay is empty". A reader draws no scrubber at all rather than an
300    #: inert one; ``0`` frames and one frame of zeroes are different
301    #: sentences.
302    frames: list[RangeReplayFrame] = Field(default_factory=list)
303    #: See :attr:`RangeProgress.milestone_count` — ``0`` means the
304    #: catalog could not resolve the challenge, so a reader renders no
305    #: fraction rather than dividing by it.
306    milestone_count: int = 0

One range wound back, as a fixed number of frames.

⚠️ Computed server-side and shipped once, which is a decision with a named alternative: a client that tallied per_segment itself at time T would be a second implementation of the milestone→segment map, and one round trip per slider step would put a fetch on every drag. So the count of frames is the knob, and the payload is bounded by it rather than by how long the event ran.

⚠️ The clock is solved_at, so the last frame equals the static /progress picture element for element — the property the feature rests on — and a capture taken before the window opened is held in frame 0 rather than appearing mid-run. ctfy.core.range_replay carries the argument.

competition_id: str = ''
challenge_id: str = ''
frames: list[RangeReplayFrame] = PydanticUndefined
milestone_count: int = 0
class RangeReplayFrame(ctfy.core.models.CtfyModel):
265class RangeReplayFrame(CtfyModel):
266    """Every team's position at one instant."""
267
268    #: ⚠️ Required and never ``None``: a frame is *the* answer at an
269    #: instant, so a frame that cannot say when it is is not a frame.
270    #: The nullable case is the whole replay being absent, which
271    #: :attr:`RangeReplay.frames` says with an empty list.
272    at: AwareDatetime
273    positions: list[RangeReplayPosition] = Field(default_factory=list)

Every team's position at one instant.

at: pydantic.types.AwareDatetime = PydanticUndefined
positions: list[RangeReplayPosition] = PydanticUndefined
class RangeReplayPosition(ctfy.core.models.CtfyModel):
231class RangeReplayPosition(CtfyModel):
232    """One team's coordinate material at one instant of the replay.
233
234    ⚠️ **Narrower than :class:`RangeProgressRow`, and the two fields are
235    the two a *track* reads.** ``frontier`` / ``available`` /
236    ``compromised`` / ``reachable`` / ``evidence`` are what the estate
237    map draws for a single picked team; a replay carries every team at
238    every instant, so publishing all of it would multiply the payload by
239    the milestone count for readers that would drop it.
240
241    ⚠️ **No identity here at all — not even a name.** The page holding
242    this already fetched ``/progress``, whose rows carry the name, the
243    avatar and the anonymity verdict; a second copy travelling per frame
244    would be one more surface to remember when the projection changes,
245    which is exactly how ``anonymise_range_progress`` warns a new field
246    goes out unprojected. The join key is ``team_id``, and the replay
247    publishes a position only for a team ``/progress`` published a row
248    for — see ``admin_range_progress``.
249    """
250
251    team_id: str = ""
252    #: How many declared milestones this team held at this instant.
253    #: ⚠️ A count, never the id list: the ids are on the progress row
254    #: and do not change per frame in any way a track renders.
255    reached: int = 0
256    #: ``segment -> [reached, total]``, the same shape and the same
257    #: producer as :attr:`RangeProgressRow.per_segment`.
258    per_segment: dict[str, list[int]] = Field(default_factory=dict)
259    #: How many of those reached milestones no estate segment carries.
260    #: ⚠️ A count here and ids on the progress row, for `reached`'s own
261    #: reason: identity lives there, a frame publishes a position.
262    off_segment: int = 0

One team's coordinate material at one instant of the replay.

⚠️ Narrower than RangeProgressRow, and the two fields are the two a track reads. frontier / available / compromised / reachable / evidence are what the estate map draws for a single picked team; a replay carries every team at every instant, so publishing all of it would multiply the payload by the milestone count for readers that would drop it.

⚠️ No identity here at all — not even a name. The page holding this already fetched /progress, whose rows carry the name, the avatar and the anonymity verdict; a second copy travelling per frame would be one more surface to remember when the projection changes, which is exactly how anonymise_range_progress warns a new field goes out unprojected. The join key is team_id, and the replay publishes a position only for a team /progress published a row for — see admin_range_progress.

team_id: str = ''
reached: int = 0
per_segment: dict[str, list[int]] = PydanticUndefined
off_segment: int = 0
class ReactionCount(ctfy.core.models.CtfyModel):
957class ReactionCount(CtfyModel):
958    """How many people gave one reaction chip."""
959
960    reaction: Reaction
961    count: int

How many people gave one reaction chip.

reaction: Literal['addictive', 'mindblown', 'learned', 'goat_setter', 'overthought', 'guessy', 'brutal', 'buggy', 'infra_broken'] = PydanticUndefined
count: int = PydanticUndefined
class ReauthenticateRequest(ctfy.core.models.CtfyModel):
368class ReauthenticateRequest(CtfyModel):
369    """Body for ``POST /me/reauthenticate`` — prove a factor again, now.
370
371    Both fields default blank and the server decides which one it wants
372    (``account_deletion.required_proof``), so a client does not have to
373    guess and an account whose answer is ``none`` sends ``{}``. That is
374    the same shape :class:`DeleteMeRequest` takes, on purpose: two
375    request models disagreeing about what counts as proof is the
376    four-routes-four-answers problem this replaces, one layer down.
377    """
378
379    current_password: str = ""
380    code: str = ""

Body for POST /me/reauthenticate — prove a factor again, now.

Both fields default blank and the server decides which one it wants (account_deletion.required_proof), so a client does not have to guess and an account whose answer is none sends {}. That is the same shape DeleteMeRequest takes, on purpose: two request models disagreeing about what counts as proof is the four-routes-four-answers problem this replaces, one layer down.

current_password: str = ''
code: str = ''
class ReauthenticationOpened(ctfy.core.models.CtfyModel):
354class ReauthenticationOpened(CtfyModel):
355    """What ``POST /me/reauthenticate`` answers with.
356
357    ⚠️ **The window travels rather than being hardcoded**, for
358    ``MeResponse.deletion_proof``'s reason: the length is an operator
359    setting, so a client that assumed one would prompt for a proof the
360    server still considers fresh — or, worse, stop prompting while the
361    server has already closed the window, which surfaces as a refusal
362    the user cannot connect to anything they did.
363    """
364
365    window_seconds: int = 0

What POST /me/reauthenticate answers with.

⚠️ The window travels rather than being hardcoded, for MeResponse.deletion_proof's reason: the length is an operator setting, so a client that assumed one would prompt for a proof the server still considers fresh — or, worse, stop prompting while the server has already closed the window, which surfaces as a refusal the user cannot connect to anything they did.

window_seconds: int = 0
class RecentUnlock(ctfy.core.models.CtfyModel):
115class RecentUnlock(CtfyModel):
116    """A single row in the platform-wide recent-unlocks feed."""
117
118    team_id: str
119    team_name: str
120    #: See :attr:`ctfy.server.models.ScoreboardEntry.anonymous`.
121    anonymous: bool = False
122    achievement_id: str
123    name: str
124    icon: str
125    tier: str
126    #: See :attr:`AchievementCatalogEntry.localized`. Always ``{}`` here — this
127    #: feed filters every secret badge out, which is what makes it safe to
128    #: carry the field at all — but it is carried, so a feed that one day
129    #: showed an earned secret badge to its owner would not need a new field.
130    localized: dict[str, AchievementCopy] = Field(default_factory=dict)
131    unlocked_at: datetime | None = None

A single row in the platform-wide recent-unlocks feed.

team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
anonymous: bool = False
achievement_id: str = PydanticUndefined
name: str = PydanticUndefined
icon: str = PydanticUndefined
tier: str = PydanticUndefined
localized: dict[str, AchievementCopy] = PydanticUndefined
unlocked_at: datetime.datetime | None = None
class RecordAuthoredChallenge(ctfy.core.models.CtfyModel):
84class RecordAuthoredChallenge(CtfyModel):
85    """One upload into this competition, and who wrote it."""
86
87    id: str
88    title: str = ""
89    team_id: str = ""
90    uploader_user_id: str = ""
91    author_user_ids: list[str] = Field(default_factory=list)
92    review_status: ReviewStatus = "pending"
93    smoke_verdict: AuthoredSmokeState = ""
94    destination: AuthoredDestination = "public"
95    submitted_at: datetime | None = None
96    #: Whether the challenge is in this competition's set right now.
97    in_challenge_set: bool = False

One upload into this competition, and who wrote it.

id: str = PydanticUndefined
title: str = ''
team_id: str = ''
uploader_user_id: str = ''
author_user_ids: list[str] = PydanticUndefined
review_status: Literal['pending', 'approved', 'rejected'] = 'pending'
smoke_verdict: Union[Literal[''], Literal['solved', 'unsolved', 'estate_failed', 'malformed', 'errored']] = ''
destination: Literal['public', 'eval_only'] = 'public'
submitted_at: datetime.datetime | None = None
in_challenge_set: bool = False
class RecordEvalRun(ctfy.core.models.CtfyModel):
61class RecordEvalRun(CtfyModel):
62    """One dispatched agent run."""
63
64    id: str
65    team_id: str = ""
66    harness_id: str = ""
67    model_id: str = ""
68    challenge_id: str = ""
69    status: EvalRunStatus = "pending"
70    solved: list[str] = Field(default_factory=list)
71    fully_solved: bool = False
72    input_tokens: int = 0
73    output_tokens: int = 0
74    #: Calls the model gateway relayed, as opposed to what the agent
75    #: reported about itself (``usage_source``).
76    relayed_calls: int = 0
77    usage_source: str = "agent"
78    stop_reason: str = ""
79    error: str = ""
80    started_at_ts: float = 0.0
81    finished_at_ts: float = 0.0

One dispatched agent run.

id: str = PydanticUndefined
team_id: str = ''
harness_id: str = ''
model_id: str = ''
challenge_id: str = ''
status: Literal['pending', 'running', 'succeeded', 'failed', 'cancelled', 'errored', 'aborted', 'incomplete'] = 'pending'
solved: list[str] = PydanticUndefined
fully_solved: bool = False
input_tokens: int = 0
output_tokens: int = 0
relayed_calls: int = 0
usage_source: str = 'agent'
stop_reason: str = ''
error: str = ''
started_at_ts: float = 0.0
finished_at_ts: float = 0.0
class RecordSolve(ctfy.core.models.CtfyModel):
35class RecordSolve(CtfyModel):
36    """One question answered correctly, as the board ranks it."""
37
38    team_id: str
39    user_id: str = ""
40    challenge_id: str
41    question_id: str = ""
42    solved_at: datetime | None = None
43    #: ``None`` when the solve does not count in this competition — an
44    #: author on their own upload, or a capture outside the window.
45    ranked_at: datetime | None = None

One question answered correctly, as the board ranks it.

team_id: str = PydanticUndefined
user_id: str = ''
challenge_id: str = PydanticUndefined
question_id: str = ''
solved_at: datetime.datetime | None = None
ranked_at: datetime.datetime | None = None
class RecordSubmission(ctfy.core.models.CtfyModel):
48class RecordSubmission(CtfyModel):
49    """One submission. ⚠️ Never the submitted text: a correct one *is* a
50    minted answer, and this file leaves the platform."""
51
52    id: str
53    team_id: str
54    user_id: str = ""
55    challenge_id: str
56    question_id: str = ""
57    correct: bool = False
58    submitted_at: datetime | None = None

One submission. ⚠️ Never the submitted text: a correct one is a minted answer, and this file leaves the platform.

id: str = PydanticUndefined
team_id: str = PydanticUndefined
user_id: str = ''
challenge_id: str = PydanticUndefined
question_id: str = ''
correct: bool = False
submitted_at: datetime.datetime | None = None
class RecordTeam(ctfy.core.models.CtfyModel):
15class RecordTeam(CtfyModel):
16    """One entrant: a human team, or an agent's synthetic team."""
17
18    id: str
19    name: str = ""
20    entrant_kind: EntrantKind = "human"
21    #: The members' user ids. A synthetic agent team holds only its
22    #: synthetic user.
23    member_user_ids: list[str] = Field(default_factory=list)
24    #: For an agent team: the harness, the person who registered it, and
25    #: that person's own team in this competition (``""`` when they hold
26    #: none). This is the join an event that credits an agent's score to
27    #: its owner's team needs, and the synthetic id alone cannot answer it.
28    agent_harness_id: str = ""
29    agent_owner_user_id: str = ""
30    agent_owner_team_id: str = ""
31    disqualified: bool = False
32    review_status: str = ""

One entrant: a human team, or an agent's synthetic team.

id: str = PydanticUndefined
name: str = ''
entrant_kind: Literal['human', 'agent'] = 'human'
member_user_ids: list[str] = PydanticUndefined
agent_harness_id: str = ''
agent_owner_user_id: str = ''
agent_owner_team_id: str = ''
disqualified: bool = False
review_status: str = ''
class RegisterRequest(ctfy.core.models.CtfyModel):
 98class RegisterRequest(CtfyModel):
 99    email: EmailStr
100    # Length minimum mirrors ``MIN_PASSWORD_LENGTH`` in
101    # ``ctfy.server.password``. ``validate_password_strength`` runs the
102    # full check (length + common-password reject) inside the route
103    # handler so the rule lives in one place; this floor is a cheap
104    # client-side hint that catches obviously short input before it
105    # reaches argon2.
106    password: str = Field(min_length=12, max_length=256)
107    display_name: str = Field(default="", max_length=120)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

email: pydantic.networks.EmailStr = PydanticUndefined
password: str = PydanticUndefined
display_name: str = ''
class RegistrationFieldSpec(ctfy.core.models.CtfyModel):
37class RegistrationFieldSpec(CtfyModel):
38    """One field the competition's form should render.
39
40    The server drives the form rather than the frontend hard-coding it,
41    so an organiser flipping a field from optional to required takes
42    effect without a redeploy — and so the client cannot disagree with
43    the server about what is mandatory.
44    """
45
46    name: str
47    policy: RegistrationFieldPolicy
48    # True for the fields the captain answers once for the whole squad
49    # (team logo, team honours) rather than each member answering.
50    team_scoped: bool = False
51    # True for 个人敏感信息 — the frontend renders the consent notice and
52    # the "organisers only" hint against exactly these.
53    sensitive: bool = False

One field the competition's form should render.

The server drives the form rather than the frontend hard-coding it, so an organiser flipping a field from optional to required takes effect without a redeploy — and so the client cannot disagree with the server about what is mandatory.

name: str = PydanticUndefined
policy: Literal['hidden', 'optional', 'required'] = PydanticUndefined
team_scoped: bool = False
sensitive: bool = False
class RegistrationFormSpec(ctfy.core.models.CtfyModel):
56class RegistrationFormSpec(CtfyModel):
57    """Everything the client needs to render + gate the wizard."""
58
59    competition_id: str
60    fields: list[RegistrationFieldSpec] = Field(default_factory=list)
61    registration_starts_at: datetime | None = None
62    registration_ends_at: datetime | None = None
63    # "not_open" | "open" | "closed" — the client shows a countdown, the
64    # form, or a dead end respectively.
65    phase: Literal["not_open", "open", "closed"] = "open"
66    max_team_size: int = 0
67    review_required: bool = False
68    collects_sensitive_pii: bool = False
69    #: Whether this competition offers to withhold an entrant's identity,
70    #: and what withholding looks like. The wizard renders the control
71    #: only when the policy is not ``forbidden``, and renders it as a
72    #: statement rather than a choice when it is ``required``.
73    #:
74    #: ⚠️ Server-driven like every other field on this spec, and for the
75    #: same reason: a client that decided for itself whether to offer the
76    #: control would be a second copy of the organiser's policy, and the
77    #: copy that is wrong is the one that offers a promise the server
78    #: will not keep.
79    anonymous_entry: AnonymityPolicy = "forbidden"
80    anonymous_entry_kind: AnonymityKind = "pseudonymous"

Everything the client needs to render + gate the wizard.

competition_id: str = PydanticUndefined
fields: list[RegistrationFieldSpec] = PydanticUndefined
registration_starts_at: datetime.datetime | None = None
registration_ends_at: datetime.datetime | None = None
phase: Literal['not_open', 'open', 'closed'] = 'open'
max_team_size: int = 0
review_required: bool = False
collects_sensitive_pii: bool = False
anonymous_entry: Literal['forbidden', 'optional', 'required'] = 'forbidden'
anonymous_entry_kind: Literal['pseudonymous', 'hidden'] = 'pseudonymous'
class RegistrationRosterRow(ctfy.core.models.CtfyModel):
187class RegistrationRosterRow(CtfyModel):
188    """One entrant on the organiser's roster.
189
190    This is the **only** wire model that carries the sensitive fields, and
191    it is only ever returned from a route gated on
192    ``require_registration_reviewer``. Anything added here is, by
193    construction, organiser-visible personal data — think before widening
194    it.
195    """
196
197    user_id: str
198    display_name: str = ""
199    email: str = ""
200    team_id: str = ""
201    team_name: str = ""
202    is_captain: bool = False
203
204    real_name: str = ""
205    phone: str = ""
206    mailing_address: str = ""
207    # ⚠️ The same closed set the request carries, not a bare ``str`` —
208    # the `max_level` shape, where the middle layer drops the type and
209    # the client then asserts more than the server promised.
210    gender: Literal["", "male", "female", "other", "undisclosed"] = ""
211    organization: str = ""
212    academic_stage: str = ""
213    grade: str = ""
214    undergrad_school: str = ""
215    specialties: list[str] = Field(default_factory=list)
216
217    submitted_at: datetime | None = None
218    review_status: RegistrationReviewStatus = "approved"

One entrant on the organiser's roster.

This is the only wire model that carries the sensitive fields, and it is only ever returned from a route gated on require_registration_reviewer. Anything added here is, by construction, organiser-visible personal data — think before widening it.

user_id: str = PydanticUndefined
display_name: str = ''
email: str = ''
team_id: str = ''
team_name: str = ''
is_captain: bool = False
real_name: str = ''
phone: str = ''
mailing_address: str = ''
gender: Literal['', 'male', 'female', 'other', 'undisclosed'] = ''
organization: str = ''
academic_stage: str = ''
grade: str = ''
undergrad_school: str = ''
specialties: list[str] = PydanticUndefined
submitted_at: datetime.datetime | None = None
review_status: Literal['pending', 'approved', 'rejected'] = 'approved'
class RegistrationSubmitRequest(ctfy.core.models.CtfyModel):
 83class RegistrationSubmitRequest(CtfyModel):
 84    """Body for ``PUT /competitions/{id}/registration``.
 85
 86    Every field is optional at the type level; which ones are actually
 87    mandatory is decided per competition at request time against
 88    ``registration_fields``. Encoding "required" in the type would hard-
 89    code one event's rules into the wire contract.
 90    """
 91
 92    real_name: str = Field(default="", max_length=120)
 93    phone: str = Field(default="", max_length=40)
 94    mailing_address: str = Field(default="", max_length=500)
 95    gender: Literal["", "male", "female", "other", "undisclosed"] = ""
 96    organization: str = Field(default="", max_length=200)
 97    academic_stage: str = Field(default="", max_length=80)
 98    grade: str = Field(default="", max_length=80)
 99    undergrad_school: str = Field(default="", max_length=200)
100    specialties: list[str] = Field(default_factory=list)
101    # Captain-only; ignored (not rejected) when a non-captain sends them,
102    # so a member re-submitting a form the wizard prefilled does not 422.
103    team_honors: str = Field(default="", max_length=2000)
104
105    # ⚠️ **``None`` means "the form did not ask", and these two are the
106    # deliberate exception to this model's full-replace semantics.**
107    # Every other field here defaults to ``""`` because the wizard
108    # renders all of them and submits all of them, so an omission really
109    # is a clear. That reading is catastrophic for a privacy flag: a
110    # client that predates the field cannot render it, so a ``False``
111    # default would make every save from an older tab publish an entrant
112    # who asked to be withheld — silently, and with no way to know it
113    # happened. Absent is untouched.
114    #:
115    #: ``anonymous`` is the entrant's own name; ``team_anonymous`` is the
116    #: squad's row and is captain-only, ignored (not rejected) from a
117    #: member for the same reason ``team_honors`` is.
118    anonymous: bool | None = None
119    team_anonymous: bool | None = None
120
121    @field_validator("phone")
122    @classmethod
123    def _check_phone(cls, v: str) -> str:
124        return validate_phone_shape(v)
125
126    @field_validator("specialties")
127    @classmethod
128    def _check_specialties(cls, v: list[str]) -> list[str]:
129        if len(v) > 12:
130            raise ValueError("specialties may contain at most 12 entries")
131        if any(len(s) > 40 for s in v):
132            raise ValueError("each specialty must be at most 40 characters")
133        return v

Body for PUT /competitions/{id}/registration.

Every field is optional at the type level; which ones are actually mandatory is decided per competition at request time against registration_fields. Encoding "required" in the type would hard- code one event's rules into the wire contract.

real_name: str = ''
phone: str = ''
mailing_address: str = ''
gender: Literal['', 'male', 'female', 'other', 'undisclosed'] = ''
organization: str = ''
academic_stage: str = ''
grade: str = ''
undergrad_school: str = ''
specialties: list[str] = PydanticUndefined
team_honors: str = ''
anonymous: bool | None = None
team_anonymous: bool | None = None
class RegistrationSummary(ctfy.core.models.CtfyModel):
255class RegistrationSummary(CtfyModel):
256    """Per-verdict totals for one competition's roster.
257
258    Split from the roster itself because the filter tabs need counts for
259    the *whole* competition while the list is paged — counting the rows
260    in hand would make every tab report the page size. It is also far
261    cheaper: a grouped ``COUNT`` rather than a page of decrypted
262    dossiers, which is why it can be fetched on every tab render.
263
264    Carries no personal data by construction, so it is not audited as a
265    PII read.
266    """
267
268    competition_id: str
269    total: int = 0
270    pending: int = 0
271    approved: int = 0
272    rejected: int = 0
273    # Teams with at least one member missing a required answer. Counted
274    # in SQL against blank columns, never by reading the dossiers — an
275    # unanswered sealed field is stored empty, so this stays a count and
276    # not a bulk decrypt. Teams rather than people because that is the
277    # unit a reviewer works through.
278    incomplete_teams: int = 0

Per-verdict totals for one competition's roster.

Split from the roster itself because the filter tabs need counts for the whole competition while the list is paged — counting the rows in hand would make every tab report the page size. It is also far cheaper: a grouped COUNT rather than a page of decrypted dossiers, which is why it can be fetched on every tab render.

Carries no personal data by construction, so it is not audited as a PII read.

competition_id: str = PydanticUndefined
total: int = 0
pending: int = 0
approved: int = 0
rejected: int = 0
incomplete_teams: int = 0
class RegistrationTeamRow(ctfy.core.models.CtfyModel):
221class RegistrationTeamRow(CtfyModel):
222    """One squad on the organiser's review queue.
223
224    Review is a team-level verdict, so the queue is grouped by team with
225    the members nested — an organiser approves a squad after reading all
226    of its members' details, never one person at a time.
227    """
228
229    team_id: str
230    team_name: str = ""
231    description: str = ""
232    honors: str = ""
233    has_logo: bool = False
234    captain_user_id: str = ""
235    created_at: datetime | None = None
236    review_status: RegistrationReviewStatus = "approved"
237    reviewed_by: str = ""
238    reviewed_by_name: str = ""
239    reviewed_at: datetime | None = None
240    review_note: str = ""
241    members: list[RegistrationRosterRow] = Field(default_factory=list)
242    # Members who have not completed the form yet. Surfaced as a count so
243    # the reviewer can spot an incomplete squad without opening it.
244    incomplete_members: int = 0
245    # True when a member's dossier changed after the verdict was recorded,
246    # which makes that verdict stale. Derived from ``reviewed_at`` against
247    # the members' ``updated_at`` rather than stored: a column would have
248    # to be reset on every review and cleared on every edit, and a missed
249    # write there reads exactly like "nothing changed". The alternative —
250    # reverting the team to ``pending`` on any edit — would force a
251    # re-review over a corrected phone digit.
252    edited_after_review: bool = False

One squad on the organiser's review queue.

Review is a team-level verdict, so the queue is grouped by team with the members nested — an organiser approves a squad after reading all of its members' details, never one person at a time.

team_id: str = PydanticUndefined
team_name: str = ''
description: str = ''
honors: str = ''
captain_user_id: str = ''
created_at: datetime.datetime | None = None
review_status: Literal['pending', 'approved', 'rejected'] = 'approved'
reviewed_by: str = ''
reviewed_by_name: str = ''
reviewed_at: datetime.datetime | None = None
review_note: str = ''
members: list[RegistrationRosterRow] = PydanticUndefined
incomplete_members: int = 0
edited_after_review: bool = False
class RegistryGcStatus(ctfy.core.models.CtfyModel):
126class RegistryGcStatus(CtfyModel):
127    """Whether and how the registry can reclaim the blobs behind deletes.
128
129    Deleting a manifest frees only its reference; the bytes stay until the
130    registry garbage-collects. This is the probe + preview the operator
131    reads before running that — it never runs GC itself, because a stock
132    ``registry:2`` exposes no remote GC and the platform holds no shell on
133    the registry container.
134    """
135
136    reachable: bool = False
137    #: The ``Docker-Distribution-Api-Version`` the registry advertised.
138    api_version: str = ""
139    mode: RegistryGcMode = RegistryGcMode.UNAVAILABLE
140    #: Bytes the current reclaim policies would free once their manifests
141    #: are deleted and GC has run — a preview, summed over both policies.
142    reclaimable_bytes: int = 0
143    reclaimable_manifests: int = 0
144    #: The exact host command an operator runs to garbage-collect, or
145    #: ``""`` when the registry is unreachable.
146    command: str = ""
147    #: A human-readable summary of the mechanism and any caveat.
148    message: str = ""

Whether and how the registry can reclaim the blobs behind deletes.

Deleting a manifest frees only its reference; the bytes stay until the registry garbage-collects. This is the probe + preview the operator reads before running that — it never runs GC itself, because a stock registry:2 exposes no remote GC and the platform holds no shell on the registry container.

reachable: bool = False
api_version: str = ''
mode: ctfy.core.enums.RegistryGcMode = <RegistryGcMode.UNAVAILABLE: 'unavailable'>
reclaimable_bytes: int = 0
reclaimable_manifests: int = 0
command: str = ''
message: str = ''
class RegistryManifestRow(ctfy.core.models.CtfyModel):
52class RegistryManifestRow(CtfyModel):
53    """One tag, with why it is protected and whether a reclaim would take it."""
54
55    tag: str
56    #: The manifest digest a delete addresses; ``""`` if it could not be
57    #: read (which leaves it un-deletable, the safe direction).
58    digest: str = ""
59    size_bytes: int = 0
60    #: The image config's ``created`` time — the closest the registry API
61    #: offers to a push time; ``None`` when it could not be read.
62    created_at: AwareDatetime | None = None
63    kind: RegistryRepoKind
64    #: Why this manifest may not be reclaimed. Empty means nothing
65    #: references it.
66    references: list[RegistryReferenceReason] = Field(default_factory=list)
67    #: ⚠️ Composed server-side from the reference set and the reclaim
68    #: policies, never re-derived in the browser — the same rule
69    #: ``RegistryUsageRow.over_quota`` follows. A ``true`` here means some
70    #: policy would remove it; the manual delete still refuses a pinned
71    #: digest regardless.
72    reclaimable: bool = False

One tag, with why it is protected and whether a reclaim would take it.

tag: str = PydanticUndefined
digest: str = ''
size_bytes: int = 0
created_at: pydantic.types.AwareDatetime | None = None
kind: ctfy.core.enums.RegistryRepoKind = PydanticUndefined
references: list[ctfy.core.enums.RegistryReferenceReason] = PydanticUndefined
reclaimable: bool = False
class RegistryPushTarget(ctfy.core.models.CtfyModel):
648class RegistryPushTarget(CtfyModel):
649    """Where this account pushes an agent image, if anywhere.
650
651    ⚠️ ``enabled`` is composed **server-side** even though a non-empty
652    ``repository_prefix`` implies it. A component could derive it — and
653    then two clients disagree about whether the deployment has a
654    registry. Same rule as ``expects_report``: the browser is handed the
655    verdict, not the ingredients.
656
657    ⚠️ ``enabled`` means *a push would work*, not "an operator set a
658    hostname". A domain with no signing pair behind it is a registry that
659    refuses every login, and a page that printed a push command for one
660    would be sending users at a wall.
661    """
662
663    enabled: bool = False
664    #: Blank whenever ``enabled`` is false — a half-filled answer becomes
665    #: ``docker push /uid/agent``, which fails on a malformed reference
666    #: rather than on anything a reader could act on.
667    host: str = ""
668    #: ``<host>/<namespace>``. Built here rather than in the client
669    #: because the namespace scheme is what ``registry_scopes`` decides
670    #: and what the token route authorises against; a client
671    #: concatenating its own keeps working until the two disagree.
672    repository_prefix: str = ""

Where this account pushes an agent image, if anywhere.

⚠️ enabled is composed server-side even though a non-empty repository_prefix implies it. A component could derive it — and then two clients disagree about whether the deployment has a registry. Same rule as expects_report: the browser is handed the verdict, not the ingredients.

⚠️ enabled means a push would work, not "an operator set a hostname". A domain with no signing pair behind it is a registry that refuses every login, and a page that printed a push command for one would be sending users at a wall.

enabled: bool = False
host: str = ''
repository_prefix: str = ''
class RegistryQuotaInfo(ctfy.core.models.CtfyModel):
609class RegistryQuotaInfo(CtfyModel):
610    """The ceiling the rows are judged against.
611
612    ⚠️ Sent with the report because a number with no ceiling beside it is
613    not a report: "40 GiB" answers nothing, "40 of 50" is the question
614    the operator opened the page to ask.
615    """
616
617    #: ``0`` means the dimension is off — the same reading everywhere
618    #: else, never "nothing allowed".
619    max_bytes: int = 0
620    max_tags: int = 0

The ceiling the rows are judged against.

⚠️ Sent with the report because a number with no ceiling beside it is not a report: "40 GiB" answers nothing, "40 of 50" is the question the operator opened the page to ask.

max_bytes: int = 0
max_tags: int = 0
class RegistryReclaimItem(ctfy.core.models.CtfyModel):
84class RegistryReclaimItem(CtfyModel):
85    """One manifest a reclaim run would remove, or refused to."""
86
87    repository: str
88    tag: str
89    digest: str = ""
90    size_bytes: int = 0
91    #: Populated on the refused list — why the policy kept it.
92    references: list[RegistryReferenceReason] = Field(default_factory=list)

One manifest a reclaim run would remove, or refused to.

repository: str = PydanticUndefined
tag: str = PydanticUndefined
digest: str = ''
size_bytes: int = 0
references: list[ctfy.core.enums.RegistryReferenceReason] = PydanticUndefined
class RegistryReclaimPlan(ctfy.core.models.CtfyModel):
106class RegistryReclaimPlan(CtfyModel):
107    """What one policy would remove (dry run) or removed (executed)."""
108
109    policy: RegistryReclaimPolicy
110    dry_run: bool = True
111    #: What would be / was removed, and what the policy protected.
112    reclaim: list[RegistryReclaimItem] = Field(default_factory=list)
113    refused: list[RegistryReclaimItem] = Field(default_factory=list)
114    reclaim_count: int = 0
115    reclaim_bytes: int = 0
116    refused_count: int = 0
117    #: How many were actually deleted (``0`` on a dry run). ⚠️ A separate
118    #: number from ``reclaim_count`` so a partial execution — some deletes
119    #: the registry refused — is visible rather than assumed complete.
120    deleted: int = 0
121    #: Whether the catalog walk behind the plan was capped or hit an
122    #: unreadable repository; a plan over an incomplete view under-removes.
123    incomplete: bool = False

What one policy would remove (dry run) or removed (executed).

policy: ctfy.core.enums.RegistryReclaimPolicy = PydanticUndefined
dry_run: bool = True
reclaim: list[RegistryReclaimItem] = PydanticUndefined
refused: list[RegistryReclaimItem] = PydanticUndefined
reclaim_count: int = 0
reclaim_bytes: int = 0
refused_count: int = 0
deleted: int = 0
incomplete: bool = False
class RegistryReclaimRequest(ctfy.core.models.CtfyModel):
 95class RegistryReclaimRequest(CtfyModel):
 96    """Ask a reclaim policy what it would remove, or to remove it.
 97
 98    ⚠️ ``dry_run`` defaults **true**: the preview is the default and the
 99    destructive act is the explicit one, per the issue's "dry-run first".
100    """
101
102    policy: RegistryReclaimPolicy
103    dry_run: bool = True

Ask a reclaim policy what it would remove, or to remove it.

⚠️ dry_run defaults true: the preview is the default and the destructive act is the explicit one, per the issue's "dry-run first".

policy: ctfy.core.enums.RegistryReclaimPolicy = PydanticUndefined
dry_run: bool = True
class RegistryRepo(ctfy.core.models.CtfyModel):
25class RegistryRepo(CtfyModel):
26    """One repository in the platform's own registry, for the browse list."""
27
28    #: The registry-stored name, ``<account>/<repo>`` (no host).
29    name: str
30    #: The leading path segment — the account namespace.
31    account: str = ""
32    kind: RegistryRepoKind
33    #: How many tags it holds. A count, not the tags themselves — the
34    #: per-tag detail (size, push time, references) is one drill-down
35    #: request away, so the landing list stays one cheap catalog+tags walk.
36    tags: int = 0

One repository in the platform's own registry, for the browse list.

name: str = PydanticUndefined
account: str = ''
kind: ctfy.core.enums.RegistryRepoKind = PydanticUndefined
tags: int = 0
class RegistryRepoList(ctfy.core.models.CtfyModel):
39class RegistryRepoList(CtfyModel):
40    """Every repository, for the browse page's landing list."""
41
42    items: list[RegistryRepo] = Field(default_factory=list)
43    #: ⚠️ Answered because the list accepts an implicit ceiling: a bare
44    #: list cannot tell a complete answer from a truncated one. Equals
45    #: ``len(items)`` unless the walk was capped.
46    total: int = 0
47    #: Whether any repository could not be listed — the same honesty the
48    #: usage scan keeps, so a partial view never reads as complete.
49    incomplete: bool = False

Every repository, for the browse page's landing list.

items: list[RegistryRepo] = PydanticUndefined
total: int = 0
incomplete: bool = False
class RegistryTagList(ctfy.core.models.CtfyModel):
75class RegistryTagList(CtfyModel):
76    """The tags of one repository, for the browse drill-down."""
77
78    repository: str
79    items: list[RegistryManifestRow] = Field(default_factory=list)
80    total: int = 0
81    incomplete: bool = False

The tags of one repository, for the browse drill-down.

repository: str = PydanticUndefined
items: list[RegistryManifestRow] = PydanticUndefined
total: int = 0
incomplete: bool = False
class RegistryUsageReport(ctfy.core.models.CtfyModel):
623class RegistryUsageReport(CtfyModel):
624    """Who holds what in the platform's own registry."""
625
626    items: list[RegistryUsageRow] = Field(default_factory=list)
627    quota: RegistryQuotaInfo = Field(default_factory=RegistryQuotaInfo)
628    #: When the scan last *completed*, read off the receipt it writes on
629    #: the far side of a finished walk. ⚠️ It used to be
630    #: ``max(measured_at)`` over the rows, which cannot answer the
631    #: question this field is for: with no rows that is `None` both for a
632    #: registry nobody has pushed to and for a scan that has never once
633    #: worked, and the page then says "not measured **yet**" about a loop
634    #: that will never measure anything.
635    scanned_at: AwareDatetime | None = None
636    #: Whether the quota is deciding anything right now, composed
637    #: **server-side** from that receipt. ⚠️ The verdict rather than the
638    #: timestamp, for the reason ``RegistryPushTarget.enabled`` gives
639    #: below: a page dividing a date by an interval is a second
640    #: implementation of the rule, and the copy an operator reads would
641    #: eventually disagree with the thing actually admitting pushes.
642    #: ⚠️ Defaults to ``in_force`` so an older server does not paint an
643    #: alarm across a page that is fine — the same direction
644    #: ``MySolveSummary.ranked`` defaults in, and for the same reason.
645    enforcement: Enforcement = "in_force"

Who holds what in the platform's own registry.

items: list[RegistryUsageRow] = PydanticUndefined
quota: RegistryQuotaInfo = PydanticUndefined
scanned_at: pydantic.types.AwareDatetime | None = None
enforcement: Literal['in_force', 'never_measured', 'stale'] = 'in_force'
class RegistryUsageRow(ctfy.core.models.CtfyModel):
573class RegistryUsageRow(CtfyModel):
574    """One account's registry footprint, as the operator's report shows it."""
575
576    user_id: str
577    display_name: str = ""
578    bytes: int = 0
579    tags: int = 0
580    repos: int = 0
581    #: The repository names, so the reclaim entry point beside this table
582    #: has something to point at. ⚠️ **Not read off the harness table.**
583    #: The push grant covers ``<user>/*``, so an account holds
584    #: repositories ctfy was never told about — which is precisely the
585    #: storage nobody is watching, and precisely what a report built
586    #: from registered images would omit.
587    repositories: list[str] = Field(default_factory=list)
588    measured_at: AwareDatetime | None = None
589    #: ⚠️ Carried, never folded into the numbers. A namespace whose
590    #: manifests half-failed *undercounts*, and an undercount presented
591    #: as fact is how an operator concludes an account is fine when
592    #: nobody could read half of it.
593    incomplete: bool = False
594    #: ⚠️ Composed **server-side** from the same ``check_quota`` the push
595    #: path uses, never re-derived in the browser from the two numbers
596    #: below. A page that did its own comparison would eventually
597    #: disagree with the thing that actually refuses pushes — and the
598    #: one nobody reads is the one that decides.
599    over_quota: bool = False
600    #: Whether this namespace is the deployment's platform account (the
601    #: one ``CTFY_IMAGE`` names), whose images — the frozen ``chal-…``
602    #: repositories, the deploy's own — are the platform's and are
603    #: charged to nobody (#1863). ⚠️ Composed server-side from the same
604    #: ``is_charged`` the push path asks, so the page cannot call a row
605    #: exempt that the token route still narrows.
606    platform_account: bool = False

One account's registry footprint, as the operator's report shows it.

user_id: str = PydanticUndefined
display_name: str = ''
bytes: int = 0
tags: int = 0
repos: int = 0
repositories: list[str] = PydanticUndefined
measured_at: pydantic.types.AwareDatetime | None = None
incomplete: bool = False
over_quota: bool = False
platform_account: bool = False
class RenewResponse(ctfy.core.models.CtfyModel):
563class RenewResponse(CtfyModel):
564    #: ⚠️ **An acknowledgement, not a lifecycle status.** Its two
565    #: siblings above report an :class:`InstanceStatus`; ``"renewed"`` is
566    #: not one — a renewed instance is still ``ready``. The field name
567    #: was the only thing suggesting otherwise, and a shared name across
568    #: three responses is what made the difference invisible. Typed as
569    #: the constant it is, so the next reader does not have to work out
570    #: which vocabulary this one speaks.
571    status: Literal["renewed"] = "renewed"
572    id: str
573    expires_at: float

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

status: Literal['renewed'] = 'renewed'
id: str = PydanticUndefined
expires_at: float = PydanticUndefined
class ReportAttachmentInfo(ctfy.core.models.CtfyModel):
229class ReportAttachmentInfo(CtfyModel):
230    """One evidence file in a team's report appendix.
231
232    ⚠️ **No bytes and no URL.** The bytes are fetched from
233    ``GET .../report/attachments/{id}`` — proxied through the platform
234    like a team logo, so the bucket needs no public-read policy and an
235    unpublished competition's evidence is not enumerable by anyone who
236    guesses a key. Embedding the object key here would publish exactly
237    that guess.
238    """
239
240    id: str = ""
241    #: The name the uploader gave, sanitised for display. ⚠️ Display
242    #: only — the object key is derived from ``id``, so this string
243    #: addresses nothing.
244    filename: str = ""
245    #: **Sniffed from the bytes.** A real image type means a client may
246    #: render a thumbnail; ``application/octet-stream`` means the file is
247    #: a download and nothing more.
248    content_type: str = ""
249    size_bytes: int = 0
250    #: Who uploaded it — the team owns the evidence, the user is the
251    #: audit, the same split ``ReportInfo.user_id`` makes.
252    user_id: str = ""
253    uploaded_at: datetime | None = None

One evidence file in a team's report appendix.

⚠️ No bytes and no URL. The bytes are fetched from GET .../report/attachments/{id} — proxied through the platform like a team logo, so the bucket needs no public-read policy and an unpublished competition's evidence is not enumerable by anyone who guesses a key. Embedding the object key here would publish exactly that guess.

id: str = ''
filename: str = ''
content_type: str = ''
size_bytes: int = 0
user_id: str = ''
uploaded_at: datetime.datetime | None = None
class ReportAttachmentsResponse(ctfy.core.models.CtfyModel):
256class ReportAttachmentsResponse(CtfyModel):
257    """One engagement's appendix, oldest first.
258
259    Oldest first because a reference in the prose counts from the order
260    the team built the appendix in, and a newest-first list silently
261    renumbers it on every upload.
262    """
263
264    competition_id: str = ""
265    team_id: str = ""
266    challenge_id: str = ""
267    attachments: list[ReportAttachmentInfo] = Field(default_factory=list)
268    #: Whether this deployment can accept an upload at all.
269    #:
270    #: ⚠️ **An empty appendix has to say which emptiness it is.** With no
271    #: object storage configured the list route still answers (reading
272    #: back what you submitted is not a submission), so an empty array
273    #: reads as "this team uploaded nothing" when it means "nobody can" —
274    #: and the player only discovers the difference by trying. Same
275    #: argument as ``webhook_configured`` on the email-suppression list
276    #: and ``gateway_configured`` on the LLM budget roster.
277    #:
278    #: Defaults ``False`` because that is the answer that is *not* a new
279    #: claim: an older server sending nothing must not make a client
280    #: promise an upload the deployment cannot perform.
281    uploads_enabled: bool = False
282    #: How many more this engagement may take.
283    #:
284    #: Served rather than derived from ``len(attachments)`` against a
285    #: constant the client restates: a ceiling spelled in two places is
286    #: one a client eventually disagrees with, and the direction it
287    #: disagrees in is a disabled upload button on an engagement that had
288    #: room. Zero means the appendix is full — a real state, distinct
289    #: from an appendix that is merely empty.
290    remaining: int = 0

One engagement's appendix, oldest first.

Oldest first because a reference in the prose counts from the order the team built the appendix in, and a newest-first list silently renumbers it on every upload.

competition_id: str = ''
team_id: str = ''
challenge_id: str = ''
attachments: list[ReportAttachmentInfo] = PydanticUndefined
uploads_enabled: bool = False
remaining: int = 0
class ReportClaimRow(ctfy.core.models.CtfyModel):
546class ReportClaimRow(CtfyModel):
547    """One finding a report claims that the platform could not place.
548
549    ``#1582``'s second operator question. ⚠️ **Never scored**: whether an
550    unintended path is a finding or a defect in the range is a judgement,
551    and a grader that scored it would be paying the model for the
552    scaffolding's mistakes.
553
554    ⚠️ **``quote`` is the team's prose and ``title`` is the model's
555    paraphrase.** A row only exists once the quote was located verbatim
556    in the report, so what an operator reads is always something the
557    team wrote — with a label attached for scanning.
558    """
559
560    #: Position in the report's own order, which is the model's
561    #: enumeration order. Published so a reader can cite a row.
562    ordinal: int = 0
563    title: str = ""
564    #: Empty is an ordinary answer: a finding that is a condition rather
565    #: than a value turns on no single string.
566    value: str = ""
567    quote: str = ""

One finding a report claims that the platform could not place.

#1582's second operator question. ⚠️ Never scored: whether an unintended path is a finding or a defect in the range is a judgement, and a grader that scored it would be paying the model for the scaffolding's mistakes.

⚠️ quote is the team's prose and title is the model's paraphrase. A row only exists once the quote was located verbatim in the report, so what an operator reads is always something the team wrote — with a label attached for scanning.

ordinal: int = 0
title: str = ''
value: str = ''
quote: str = ''
class ReportClaimsPage(ctfy.core.models.CtfyModel):
570class ReportClaimsPage(CtfyModel):
571    """What one team's report claims beyond what the range declares.
572
573    ⚠️ **``read`` is the field that makes the rest honest.** Zero rows
574    has three causes: the platform read the report and could place
575    everything, nothing ever looked, or the model call was refused. Only
576    the first is a fact about the team. Without this flag an operator
577    reading an empty table cannot tell "they found nothing we did not
578    plant" from "this deployment has no gateway", and those get opposite
579    responses.
580
581    ⚠️ **Defaults ``False``**, so a server that does not send it reads as
582    *not read* rather than as a clean sheet — the claims-nothing
583    direction, since the alarming reading here is the reassuring one.
584    """
585
586    #: Which saved version was read. ``0`` when nothing has been.
587    report_version: int = 0
588    #: Has any version of this report been read at all.
589    read: bool = False
590    #: The rows this page carries, already cut to the operator's setting.
591    rows: list[ReportClaimRow] = Field(default_factory=list)
592    #: How many the reading found, before the cut. ⚠️ A route that takes
593    #: a limit owes a total — ``rows`` shorter than this is a list that
594    #: was cut, and without the number a short list reads as a quiet
595    #: report.
596    total: int = 0
597    #: How many claims the platform *could* tie to a declared question.
598    #: Published because it is what says the subtraction ran at all: a
599    #: long list beside ``accounted: 0`` is a reading with nothing to
600    #: subtract against, which is a different problem from a range with
601    #: unintended paths in it.
602    accounted: int = 0
603    #: How many spans the model produced that are not in the report.
604    #: ⚠️ A signal about the *deployment*, never about the team — it
605    #: counts the model inventing, and its sibling on the grading side
606    #: is ``unreadable_answers``.
607    ungrounded: int = 0

What one team's report claims beyond what the range declares.

⚠️ read is the field that makes the rest honest. Zero rows has three causes: the platform read the report and could place everything, nothing ever looked, or the model call was refused. Only the first is a fact about the team. Without this flag an operator reading an empty table cannot tell "they found nothing we did not plant" from "this deployment has no gateway", and those get opposite responses.

⚠️ Defaults False, so a server that does not send it reads as not read rather than as a clean sheet — the claims-nothing direction, since the alarming reading here is the reassuring one.

report_version: int = 0
read: bool = False
rows: list[ReportClaimRow] = PydanticUndefined
total: int = 0
accounted: int = 0
ungrounded: int = 0
class ReportGradeInfo(ctfy.core.models.CtfyModel):
328class ReportGradeInfo(CtfyModel):
329    """One grading run in full, per-fact verdicts included.
330
331    What an appeal argues from, so it carries the half a count cannot:
332    *which* finding was called inaccurate. Organiser-only —
333    ``unsupported`` beside a fact key is an accusation with a name on it.
334    """
335
336    team_id: str = ""
337    challenge_id: str = ""
338    report_version: int = 0
339    #: Per-fact verdicts keyed ``challenge_id:question_id``. Kept whole
340    #: rather than only the non-``accurate`` ones, so a run states the
341    #: entire fact set it was computed over — otherwise an absent key
342    #: means both "graded accurate" and "was not a fact at the time".
343    verdicts: dict[str, str] = Field(default_factory=dict)
344    accurate: int = 0
345    inaccurate: int = 0
346    not_mentioned: int = 0
347    unsupported: int = 0
348    points: int = 0
349    graded_at: datetime | None = None

One grading run in full, per-fact verdicts included.

What an appeal argues from, so it carries the half a count cannot: which finding was called inaccurate. Organiser-only — unsupported beside a fact key is an accusation with a name on it.

team_id: str = ''
challenge_id: str = ''
report_version: int = 0
verdicts: dict[str, str] = PydanticUndefined
accurate: int = 0
inaccurate: int = 0
not_mentioned: int = 0
unsupported: int = 0
points: int = 0
graded_at: datetime.datetime | None = None
class ReportGradeSummary(ctfy.core.models.CtfyModel):
293class ReportGradeSummary(CtfyModel):
294    """One team's live Reporting verdict on one range, without the detail.
295
296    The counts and the price, for a roster row and a wall panel. The
297    per-fact verdicts are one route further in
298    (:class:`ReportGradeInfo`), the same split the report body itself
299    makes: a table of "how did everyone do" must not carry thirty-three
300    judgements per row.
301
302    ⚠️ **``None`` on a row, never an all-zero summary.** A report graded
303    against an empty fact set records exactly this object with every
304    count at zero — a real verdict, produced with no model call, about a
305    team that wrote up a range it never got into — so a zeroed default
306    would be indistinguishable from *never graded*. The two are opposite
307    facts about whether the platform owes the team anything.
308    """
309
310    #: Which saved version this verdict is about. ⚠️ Compare it with the
311    #: row's own ``version``: a team that has saved again since is
312    #: reading a judgement of a draft they have replaced, and that is
313    #: the whole reason the grade row records a version at all.
314    report_version: int = 0
315    accurate: int = 0
316    inaccurate: int = 0
317    not_mentioned: int = 0
318    #: Findings claimed with nothing behind them — §3.4's fabrication
319    #: count, and the reason this whole object is organiser-facing.
320    unsupported: int = 0
321    #: What those counts were worth **under the weights in force when
322    #: the verdict was produced**. Never re-priced downstream: a model's
323    #: verdict is not reproducible, so that row is its only account.
324    points: int = 0
325    graded_at: datetime | None = None

One team's live Reporting verdict on one range, without the detail.

The counts and the price, for a roster row and a wall panel. The per-fact verdicts are one route further in (ReportGradeInfo), the same split the report body itself makes: a table of "how did everyone do" must not carry thirty-three judgements per row.

⚠️ None on a row, never an all-zero summary. A report graded against an empty fact set records exactly this object with every count at zero — a real verdict, produced with no model call, about a team that wrote up a range it never got into — so a zeroed default would be indistinguishable from never graded. The two are opposite facts about whether the platform owes the team anything.

report_version: int = 0
accurate: int = 0
inaccurate: int = 0
not_mentioned: int = 0
unsupported: int = 0
points: int = 0
graded_at: datetime.datetime | None = None
class ReportGradesResponse(ctfy.core.models.CtfyModel):
352class ReportGradesResponse(CtfyModel):
353    """Every grading run on one team's report for one range, newest first.
354
355    ⚠️ **Its own organiser route rather than a field on
356    :class:`ReportVersionsResponse`**, which a player reads for their own
357    history. Widening that one would make an empty ``runs`` list mean
358    both "never graded" and "not disclosed to you" — and the route that
359    must never serve these would then be one forgotten branch away from
360    doing it, instead of being structurally unable to.
361    """
362
363    competition_id: str = ""
364    team_id: str = ""
365    challenge_id: str = ""
366    runs: list[ReportGradeInfo] = Field(default_factory=list)

Every grading run on one team's report for one range, newest first.

⚠️ Its own organiser route rather than a field on ReportVersionsResponse, which a player reads for their own history. Widening that one would make an empty runs list mean both "never graded" and "not disclosed to you" — and the route that must never serve these would then be one forgotten branch away from doing it, instead of being structurally unable to.

competition_id: str = ''
team_id: str = ''
challenge_id: str = ''
runs: list[ReportGradeInfo] = PydanticUndefined
class ReportInfo(ctfy.core.models.CtfyModel):
32class ReportInfo(CtfyModel):
33    """One saved version, as its own team reads it back.
34
35    ⚠️ ``content`` is attacker-authored text — every byte is written by
36    whoever is attacking the range. It is carried and stored as plain
37    markdown, never rendered as HTML by anything that receives it, and
38    when the grading half lands it reaches a model only through the
39    bounded matching prompt described in the plan's §5.4.
40    """
41
42    competition_id: str = ""
43    team_id: str = ""
44    #: The range this report is about. ⚠️ Empty on a **pre-split** row —
45    #: one written before the report was per challenge, when it covered
46    #: the whole competition. Those are read back unchanged; nothing
47    #: writes an empty one any more.
48    challenge_id: str = ""
49    #: Who pressed save on *this* version. The team is scored; the user
50    #: is the audit.
51    user_id: str = ""
52    #: 1-based, monotonic within (team, competition, challenge). The
53    #: current report is the highest; nothing renumbers.
54    version: int = 0
55    content: str = ""
56    saved_at: datetime | None = None
57    #: When the team asked for **this version** to be judged now rather
58    #: than when the quiet window elapses. ``None`` is the ordinary
59    #: state; a save writes a fresh version without it, because a team
60    #: that carried on writing has withdrawn the claim that the document
61    #: was ready.
62    grading_requested_at: datetime | None = None
63    #: When the team handed this version in — 交卷. While it is
64    #: set, the save route refuses a new version.
65    #:
66    #: ⚠️ **The timestamp, never a bool.** "When did they hand it in" is
67    #: the question a late-submission dispute asks, and it is the one a
68    #: boolean cannot answer at all.
69    finalized_at: datetime | None = None

One saved version, as its own team reads it back.

⚠️ content is attacker-authored text — every byte is written by whoever is attacking the range. It is carried and stored as plain markdown, never rendered as HTML by anything that receives it, and when the grading half lands it reaches a model only through the bounded matching prompt described in the plan's §5.4.

competition_id: str = ''
team_id: str = ''
challenge_id: str = ''
user_id: str = ''
version: int = 0
content: str = ''
saved_at: datetime.datetime | None = None
grading_requested_at: datetime.datetime | None = None
finalized_at: datetime.datetime | None = None
class ReportPipeline(ctfy.core.models.CtfyModel):
388class ReportPipeline(CtfyModel):
389    """The whole deployment's report-grading pipeline, in one read.
390
391    The competition-scoped roster answers "is *my* event moving"; this
392    answers "is the grader moving at all", which is a different question
393    with a different failure. A backlog nothing drains is byte-identical
394    to a busy one — the job leaves an ungradeable report due on purpose
395    and records ``succeeded`` while doing it — so the two facts that
396    decide whether a verdict can be produced travel beside the numbers
397    rather than being inferred from them.
398    """
399
400    #: Due across every exercise competition.
401    backlog: int = 0
402    #: Saved and ungraded but not yet quiet, across every one of them.
403    waiting: int = 0
404    #: In the job's own walk order; see :class:`ReportPipelineEvent`.
405    events: list[ReportPipelineEvent] = Field(default_factory=list)
406    #: How many reports one tick will grade, across the whole platform.
407    #: Served because it is what turns a backlog into a *duration*, and
408    #: it is a constant the client would otherwise have to restate.
409    max_per_tick: int = 0
410    #: How often that tick comes round, in seconds.
411    interval_seconds: int = 0
412    #: How long a report must sit untouched to become due.
413    quiet_seconds: int = 0
414    #: How long the oldest **due** report has been waiting, in seconds.
415    #:
416    #: ⚠️ **The reading this model's own last paragraph asks for.** It
417    #: says what proves the pipeline is working is "the backlog going
418    #: down" — which one snapshot cannot show, because a backlog nothing
419    #: drains and a busy one are the same two counts. This is the fact
420    #: that separates them without a second sample.
421    #:
422    #: ⚠️ **``None`` covers two absences and ``0.0`` covers neither**:
423    #: an empty queue has no oldest row, and a queue whose due rows are
424    #: all unstamped has one with no readable age. A zero would read as
425    #: *a report arrived a moment ago*, the opposite of *nothing is
426    #: due*, and the two get opposite responses. ``backlog`` beside it
427    #: is what tells an absent age apart from a server that did not say.
428    #:
429    #: ⚠️ **Platform-wide, and deliberately not on
430    #: :class:`ReportPipelineEvent`.** A per-event age is a true fact
431    #: that reads as a verdict about that event, while the cause is
432    #: usually the events *ahead* of it in the one shared per-tick
433    #: ceiling — which is the misreading this whole model exists to
434    #: prevent.
435    oldest_due_seconds: float | None = None
436    #: Is the recurring loop switched on for this deployment. ⚠️ Both
437    #: gates default ``False``, the answer that is not a new claim: an
438    #: older server's silence must not render as "grading is running".
439    grading_enabled: bool = False
440    #: Is there an LLM gateway for the grader's own grant to be spent
441    #: through. Without one the grader returns before building a prompt
442    #: and every report simply stays due.
443    grading_configured: bool = False
444    #: When the loop last ran, and what it said. ⚠️ **Its own report, so
445    #: it is evidence rather than health** — the job records ``ok`` for a
446    #: run that caught its own failure and returned the message. It is
447    #: published so an operator can see the pipeline is *ticking*; what
448    #: says it is *working* is the backlog going down.
449    last_run_at: datetime | None = None
450    #: ⚠️ Typed, not ``str``: it is a three-member closed set
451    #: (``ok`` / ``error`` / ``""`` for never run), and ``""`` here is
452    #: *never run on this deployment* rather than a fourth outcome.
453    last_status: JobRunStatus = ""
454    last_summary: str = ""

The whole deployment's report-grading pipeline, in one read.

The competition-scoped roster answers "is my event moving"; this answers "is the grader moving at all", which is a different question with a different failure. A backlog nothing drains is byte-identical to a busy one — the job leaves an ungradeable report due on purpose and records succeeded while doing it — so the two facts that decide whether a verdict can be produced travel beside the numbers rather than being inferred from them.

backlog: int = 0
waiting: int = 0
events: list[ReportPipelineEvent] = PydanticUndefined
max_per_tick: int = 0
interval_seconds: int = 0
quiet_seconds: int = 0
oldest_due_seconds: float | None = None
grading_enabled: bool = False
grading_configured: bool = False
last_run_at: datetime.datetime | None = None
last_status: Literal['ok', 'error', ''] = ''
last_summary: str = ''
class ReportPipelineEvent(ctfy.core.models.CtfyModel):
369class ReportPipelineEvent(CtfyModel):
370    """One exercise competition's share of the platform-wide backlog.
371
372    ⚠️ **In the order the grading job walks it**, never sorted by size.
373    ``MAX_REPORTS_PER_TICK`` is a ceiling *across every competition*, so
374    the job drains them in sequence and a competition late in that
375    sequence is starved by the ones ahead of it. A list ordered by
376    backlog would put the starving event first and read as the opposite
377    of what is happening.
378    """
379
380    competition_id: str = ""
381    title: str = ""
382    #: Reports due right now — quiet, or pushed past the window by hand.
383    queued: int = 0
384    #: Saved, ungraded, and not yet quiet.
385    waiting: int = 0

One exercise competition's share of the platform-wide backlog.

⚠️ In the order the grading job walks it, never sorted by size. MAX_REPORTS_PER_TICK is a ceiling across every competition, so the job drains them in sequence and a competition late in that sequence is starved by the ones ahead of it. A list ordered by backlog would put the starving event first and read as the opposite of what is happening.

competition_id: str = ''
title: str = ''
queued: int = 0
waiting: int = 0
class ReportRosterResponse(ctfy.core.models.CtfyModel):
503class ReportRosterResponse(CtfyModel):
504    competition_id: str = ""
505    rows: list[ReportRosterRow] = Field(default_factory=list)
506    #: Teams registered for this competition that have saved nothing **at
507    #: all**, in any range. Served rather than derived: a client would
508    #: have to fetch the full team list to subtract, and two clients
509    #: doing that arithmetic differently is two organisers disagreeing
510    #: about who is missing.
511    #:
512    #: ⚠️ Deliberately *not* "unwritten ``(team, range)`` pairs". That
513    #: number is ``teams × ranges`` for an event nobody has started and
514    #: grows with a bucket competition's whole catalog, so it answers a
515    #: question no organiser asked while looking like the one they did.
516    missing_team_count: int = 0
517    #: How many reports are queued for grading right now.
518    #:
519    #: ⚠️ **Not served for the client's arithmetic** — it holds every row
520    #: and can count them — but because it is the *headline*: an
521    #: organiser wants "18 awaiting judgement" with no table underneath
522    #: it, and a tally the page did itself would be a second answer free
523    #: to disagree with the one the wall is showing the room.
524    queue_length: int = 0
525    #: Seconds a report must sit untouched before it becomes due, so the
526    #: page can say how long ``waiting`` lasts rather than only that it
527    #: is not over.
528    quiet_seconds: int = 0
529    #: Is the recurring grading loop switched on for this deployment.
530    #:
531    #: ⚠️ **Both of these travel with the backlog because the backlog
532    #: cannot say it.** A queue nothing is draining looks exactly like a
533    #: busy one — the job leaves an ungradeable report due on purpose,
534    #: and records ``succeeded`` while doing it — so an organiser reading
535    #: only the rows would see a healthy event right up until the
536    #: Reporting column stayed empty at the close. Defaults ``False``
537    #: because that is the answer that is not a new claim: an older
538    #: server's silence must not render as "grading is running".
539    grading_enabled: bool = False
540    #: Is there an LLM gateway for the platform's own grant to be spent
541    #: through. Without one the grader returns before building a prompt
542    #: and the report simply stays due.
543    grading_configured: bool = False

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

competition_id: str = ''
rows: list[ReportRosterRow] = PydanticUndefined
missing_team_count: int = 0
queue_length: int = 0
quiet_seconds: int = 0
grading_enabled: bool = False
grading_configured: bool = False
class ReportRosterRow(ctfy.core.models.CtfyModel):
457class ReportRosterRow(CtfyModel):
458    """One team's report *status* on the organiser's roster.
459
460    ⚠️ **No body.** An event accumulates reports all week and a table of
461    "who has written one" must not read every one of them out of SQLite
462    to render a row count — the same argument that made
463    ``list_patch_submissions_admin`` name its columns rather than select
464    the entity. The body is one route further in, one team at a time.
465    """
466
467    team_id: str = ""
468    team_name: str = ""
469    #: The range. ⚠️ Carried rather than named: a ``challenge_id`` is
470    #: already human-readable (``GAUNTLET-FIN-001``), unlike a team's
471    #: uuid, so a second lookup would buy nothing — the same reading
472    #: ``EntityCell``'s own note records.
473    challenge_id: str = ""
474    version: int = 0
475    #: Length of the current version, in characters. What an organiser
476    #: scans the roster for: who has written nothing, who has written a
477    #: paragraph, who has written a report.
478    length: int = 0
479    saved_at: datetime | None = None
480    #: Where this write-up stands with the report grader.
481    status: ReportGradeStatus = "not_scored"
482    #: 1-based place in the grading queue, ``0`` unless ``status`` is
483    #: ``queued``. The organiser's is never withheld — the queue is the
484    #: thing they are on this page to manage.
485    queue_position: int = 0
486    #: The live verdict, or ``None`` where none has ever been produced.
487    #:
488    #: ⚠️ **A verdict beside a ``waiting`` or ``queued`` status is the
489    #: normal case, not a contradiction**: a team that saves a revision
490    #: after being graded is back in the queue while the previous
491    #: judgement is still the one the board reads.
492    #: ``grade.report_version`` against ``version`` is what says so.
493    grade: ReportGradeSummary | None = None
494    #: When this team handed this range in — 交卷 — or ``None``.
495    #:
496    #: ⚠️ **A different fact from every status here**, and the one an
497    #: organiser at the deadline is actually reading for: ``waiting``
498    #: covers both "still typing" and "finished ten minutes ago", which
499    #: are opposite answers to "is this team done?".
500    finalized_at: datetime | None = None

One team's report status on the organiser's roster.

⚠️ No body. An event accumulates reports all week and a table of "who has written one" must not read every one of them out of SQLite to render a row count — the same argument that made list_patch_submissions_admin name its columns rather than select the entity. The body is one route further in, one team at a time.

team_id: str = ''
team_name: str = ''
challenge_id: str = ''
version: int = 0
length: int = 0
saved_at: datetime.datetime | None = None
status: Literal['not_scored', 'waiting', 'queued', 'graded'] = 'not_scored'
queue_position: int = 0
grade: ReportGradeSummary | None = None
finalized_at: datetime.datetime | None = None
class ReportSaveRequest(ctfy.core.models.CtfyModel):
19class ReportSaveRequest(CtfyModel):
20    """A new version of the caller's team report.
21
22    ⚠️ Deliberately **not** a patch/append shape. A report is one
23    document the team owns; sending the whole thing each time is what
24    makes "the current report" unambiguous, and what lets the server
25    keep every version without having to replay a chain of edits to
26    reconstruct any of them.
27    """
28
29    content: str

A new version of the caller's team report.

⚠️ Deliberately not a patch/append shape. A report is one document the team owns; sending the whole thing each time is what makes "the current report" unambiguous, and what lets the server keep every version without having to replay a chain of edits to reconstruct any of them.

content: str = PydanticUndefined
class ReportSaveResponse(ctfy.core.models.CtfyModel):
102class ReportSaveResponse(CtfyModel):
103    """What one save did — the version it wrote, and what it credited.
104
105    ⚠️ **Its own model rather than a field on :class:`ReportInfo`.**
106    "How many findings did this save credit" is a fact about *this
107    write*; on a read, or on a row in the version history, the same field
108    would have to default to zero, which reads as "nothing here was worth
109    anything" about versions that may have been worth a great deal.
110    """
111
112    report: ReportInfo
113    #: Sentinels this save credited that the team did not already hold.
114    #:
115    #: This is the whole feedback channel the exercise format allows: a
116    #: player learns the last edit was worth something, and never learns
117    #: how many sentinels exist or how many remain
118    #: (``docs/gauntlet-exercise-plan.md`` §4).
119    newly_credited: int = 0
120    #: Where this save leaves the report with the report grader.
121    #:
122    #: ⚠️ **On the save rather than on :class:`ReportInfo`**, for the
123    #: reason ``newly_credited`` is: a queue standing is true of the
124    #: *current* report, and a row in the version history has none — a
125    #: field there would have to default to something, and every value
126    #: it could default to is a claim about a document that has been
127    #: superseded.
128    #:
129    #: A save always lands here as ``waiting`` in an exercise
130    #: competition (the save is what restarts the quiet window) and as
131    #: ``not_scored`` everywhere else, which is exactly the sentence a
132    #: player needs after pressing the button.
133    status: ReportGradeStatus = "not_scored"

What one save did — the version it wrote, and what it credited.

⚠️ Its own model rather than a field on ReportInfo. "How many findings did this save credit" is a fact about this write; on a read, or on a row in the version history, the same field would have to default to zero, which reads as "nothing here was worth anything" about versions that may have been worth a great deal.

report: ReportInfo = PydanticUndefined
newly_credited: int = 0
status: Literal['not_scored', 'waiting', 'queued', 'graded'] = 'not_scored'
class ReportStanding(ctfy.core.models.CtfyModel):
72class ReportStanding(CtfyModel):
73    """Where one range's report stands — what every verb on it reads back.
74
75    ⚠️ **Read back from the store, never composed from the request.**
76    ``admin_awd._info``'s rule: a reply assembled out of what the caller
77    asked for says the write happened whether or not it did, and the two
78    facts here that a client cannot recompute — the queue position and
79    what is left of the grading quota — are exactly the ones a hopeful
80    reply would get wrong.
81    """
82
83    report: ReportInfo
84    #: Where the current version stands with the report grader.
85    status: ReportGradeStatus = "not_scored"
86    #: 1-based place in the grading queue, ``0`` unless ``queued`` — and
87    #: ``0`` in a silent competition too, which ``status`` tells apart.
88    queue_position: int = 0
89    #: The competition's whole backlog; ``0`` when silent.
90    queue_length: int = 0
91    #: How many times this team has pushed this range's report past the
92    #: quiet window by hand.
93    grade_requests_used: int = 0
94    #: The ceiling on that. ⚠️ **``0`` is unlimited**, the reading every
95    #: other quota on this platform takes — so a client must compare
96    #: against it rather than treating it as "none left", which is the
97    #: reading that greys out the button on the deployments that ration
98    #: nothing.
99    grade_requests_limit: int = 0

Where one range's report stands — what every verb on it reads back.

⚠️ Read back from the store, never composed from the request. admin_awd._info's rule: a reply assembled out of what the caller asked for says the write happened whether or not it did, and the two facts here that a client cannot recompute — the queue position and what is left of the grading quota — are exactly the ones a hopeful reply would get wrong.

report: ReportInfo = PydanticUndefined
status: Literal['not_scored', 'waiting', 'queued', 'graded'] = 'not_scored'
queue_position: int = 0
queue_length: int = 0
grade_requests_used: int = 0
grade_requests_limit: int = 0
class ReportVersionsResponse(ctfy.core.models.CtfyModel):
136class ReportVersionsResponse(CtfyModel):
137    """Every version a team has saved, newest first.
138
139    ⚠️ Carries the bodies. That is right for one team's own history and
140    for an organiser reading one team, and wrong for a competition-wide
141    roster — which is why :class:`ReportRosterRow` exists rather than
142    this being reused there.
143    """
144
145    competition_id: str = ""
146    team_id: str = ""
147    challenge_id: str = ""
148    versions: list[ReportInfo] = Field(default_factory=list)

Every version a team has saved, newest first.

⚠️ Carries the bodies. That is right for one team's own history and for an organiser reading one team, and wrong for a competition-wide roster — which is why ReportRosterRow exists rather than this being reused there.

competition_id: str = ''
team_id: str = ''
challenge_id: str = ''
versions: list[ReportInfo] = PydanticUndefined
class RevealedAnswer(ctfy.core.models.CtfyModel):
349class RevealedAnswer(CtfyModel):
350    """One minted answer, as an organiser is shown it."""
351
352    question_id: str
353    value: str

One minted answer, as an organiser is shown it.

question_id: str = PydanticUndefined
value: str = PydanticUndefined
class ReviewDecisionRequest(ctfy.core.models.CtfyModel):
281class ReviewDecisionRequest(CtfyModel):
282    """Body for the organiser's approve / reject on one team."""
283
284    status: Literal["approved", "rejected", "pending"]
285    # Mandatory on rejection: a rejected team is told why, and "no reason
286    # given" turns into an organiser support ticket every time.
287    note: str = Field(default="", max_length=2000)
288
289    @field_validator("note")
290    @classmethod
291    def _note_required_on_reject(cls, v: str, info) -> str:
292        if info.data.get("status") == "rejected" and not v.strip():
293            raise ValueError("a note explaining the rejection is required")
294        return v

Body for the organiser's approve / reject on one team.

status: Literal['approved', 'rejected', 'pending'] = PydanticUndefined
note: str = ''
class RunTranscriptRequest(ctfy.core.models.CtfyModel):
1330class RunTranscriptRequest(CtfyModel):
1331    """Publish this run's agent's full conversation, or take it down.
1332
1333    ``RunWallRequest``'s reasoning exactly — required, no default,
1334    because *publish* asserts consent and *withdraw* undoes it and there
1335    is no safe third state. A separate request from the wall one because
1336    it is a separate, stronger consent.
1337    """
1338
1339    published: bool

Publish this run's agent's full conversation, or take it down.

RunWallRequest's reasoning exactly — required, no default, because publish asserts consent and withdraw undoes it and there is no safe third state. A separate request from the wall one because it is a separate, stronger consent.

published: bool = PydanticUndefined
class RunWallRequest(ctfy.core.models.CtfyModel):
1316class RunWallRequest(CtfyModel):
1317    """Put this run's agent on the event's wall, or take it off.
1318
1319    ⚠️ **Required, with no default.** A body that omitted it would have
1320    to mean one of the two, and *publish* is a claim about somebody's
1321    consent while *withdraw* silently undoes a decision they made — so
1322    neither is safe to assume, and there is nothing a third state could
1323    say. ``TokenScopeUpdate``'s three-valued fields exist because a
1324    PATCH has a *leave alone*; this is one switch and a POST.
1325    """
1326
1327    published: bool

Put this run's agent on the event's wall, or take it off.

⚠️ Required, with no default. A body that omitted it would have to mean one of the two, and publish is a claim about somebody's consent while withdraw silently undoes a decision they made — so neither is safe to assume, and there is nothing a third state could say. TokenScopeUpdate's three-valued fields exist because a PATCH has a leave alone; this is one switch and a POST.

published: bool = PydanticUndefined
class RunWriteup(ctfy.core.models.CtfyModel):
1185class RunWriteup(CtfyModel):
1186    """What an agent said it did, in its own words.
1187
1188    ⚠️ **Rendered as plain text, never markdown and never HTML.** The
1189    same rule :class:`LlmCallBody` states, for a stronger reason: this
1190    prose was composed by an image the platform did not build, and
1191    ``CTFY-0001`` invites it to write markdown, so the temptation to
1192    render it is the whole risk.
1193
1194    ⚠️ **Not graded, and the model says so where a reader will see it.**
1195    Scoring rides the agent's own ``POST /submissions``; a write-up is
1196    telemetry beside the score, never a second grading path.
1197    """
1198
1199    run_id: str = ""
1200    text: str = ""

What an agent said it did, in its own words.

⚠️ Rendered as plain text, never markdown and never HTML. The same rule LlmCallBody states, for a stronger reason: this prose was composed by an image the platform did not build, and CTFY-0001 invites it to write markdown, so the temptation to render it is the whole risk.

⚠️ Not graded, and the model says so where a reader will see it. Scoring rides the agent's own POST /submissions; a write-up is telemetry beside the score, never a second grading path.

run_id: str = ''
text: str = ''
class ScheduledJobInfo(ctfy.core.models.CtfyModel):
1127class ScheduledJobInfo(CtfyModel):
1128    """One recurring job on ``GET /admin/scheduled-jobs``: a projection of
1129    ``ScheduledJobState`` plus the code-side ``default_interval_s`` so the
1130    UI can show "(default: 30s)" next to a tuned value."""
1131
1132    name: str = ""
1133    enabled: bool = True
1134    interval_s: int = 0
1135    default_interval_s: int = 0
1136    last_run_at_ts: float = 0.0
1137    last_status: JobRunStatus = ""
1138    last_summary: str = ""
1139    last_error: str = ""
1140    last_duration_s: float = 0.0
1141    run_count: int = 0
1142    updated_by_user_id: str = ""

One recurring job on GET /admin/scheduled-jobs: a projection of ScheduledJobState plus the code-side default_interval_s so the UI can show "(default: 30s)" next to a tuned value.

name: str = ''
enabled: bool = True
interval_s: int = 0
default_interval_s: int = 0
last_run_at_ts: float = 0.0
last_status: Literal['ok', 'error', ''] = ''
last_summary: str = ''
last_error: str = ''
last_duration_s: float = 0.0
run_count: int = 0
updated_by_user_id: str = ''
class ScheduledJobPatch(ctfy.core.models.CtfyModel):
1145class ScheduledJobPatch(CtfyModel):
1146    """Body of ``PATCH /admin/scheduled-jobs/{name}``. Either field may be
1147    omitted; ``interval_s`` is clamped to ``[5, 86400]`` by the route."""
1148
1149    enabled: bool | None = None
1150    interval_s: int | None = None

Body of PATCH /admin/scheduled-jobs/{name}. Either field may be omitted; interval_s is clamped to [5, 86400] by the route.

enabled: bool | None = None
interval_s: int | None = None
class ScopeCatalogEntry(ctfy.core.models.CtfyModel):
416class ScopeCatalogEntry(CtfyModel):
417    """One assignable permission category, for the token-creation UI."""
418
419    key: str
420    label: str
421    description: str
422    competition_scoped: bool
423    #: Highest level a user may grant for this category.
424    #:
425    #: ⚠️ **The wire was the loose link in a chain of three.**
426    #: ``token_scopes.ScopeCategoryInfo.max_level`` is typed
427    #: ``PermissionLevel``, this copy of it was ``str`` with the members
428    #: in a comment, and ``frontend/src/api.ts`` then declared the same
429    #: field ``PermissionLevel`` again — so the client's stricter claim
430    #: rested on a promise the middle layer had dropped. It is also the
431    #: value ``clamp_permissions`` indexes its rank table with, which is
432    #: where an unlisted level becomes a ``KeyError``.
433    max_level: PermissionLevel

One assignable permission category, for the token-creation UI.

key: str = PydanticUndefined
label: str = PydanticUndefined
description: str = PydanticUndefined
competition_scoped: bool = PydanticUndefined
max_level: Literal['none', 'read', 'write'] = PydanticUndefined
class ScoreBucket(ctfy.core.models.CtfyModel):
1352class ScoreBucket(CtfyModel):
1353    """One bucket of the scoreboard score-distribution histogram."""
1354
1355    label: str
1356    count: int = 0

One bucket of the scoreboard score-distribution histogram.

label: str = PydanticUndefined
count: int = 0
class ScoreHistoryPoint(ctfy.core.models.CtfyModel):
1242class ScoreHistoryPoint(CtfyModel):
1243    """One sample on a team's score/rank-over-time curve.
1244
1245    Both metrics travel together so the frontend can toggle
1246    Score ⇄ Rank without a refetch.
1247    """
1248
1249    ts: float = 0.0  # Unix seconds (solve time of the driving event)
1250    score: int = 0  # Cumulative flags captured up to ``ts``
1251    rank: int = 0  # 1-based rank among scoring teams at ``ts``

One sample on a team's score/rank-over-time curve.

Both metrics travel together so the frontend can toggle Score ⇄ Rank without a refetch.

ts: float = 0.0
score: int = 0
rank: int = 0
class ScoreHistorySeries(ctfy.core.models.CtfyModel):
1254class ScoreHistorySeries(CtfyModel):
1255    team_id: str
1256    team_name: str
1257    points: list[ScoreHistoryPoint] = Field(default_factory=list)
1258    #: See :attr:`ScoreboardEntry.anonymous`. The curve keeps its shape
1259    #: under the alias, which is what the alias being stable buys.
1260    anonymous: bool = False

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
points: list[ScoreHistoryPoint] = PydanticUndefined
anonymous: bool = False
class ScoreboardEntry(ctfy.core.models.CtfyModel):
1101class ScoreboardEntry(CtfyModel):
1102    rank: int = 0
1103    team_id: str
1104    team_name: str
1105    # Challenges where the team captured every declared flag. Preserved
1106    # for parity with the legacy single-flag scoreboard.
1107    solved: int = 0
1108    # Total individual flags captured across all challenges. Primary
1109    # scoreboard metric now that multi-flag challenges exist — rewards
1110    # partial progress (e.g. foothold without root).
1111    flags_solved: int = 0
1112    # The number this board is ranked by, as decided by the
1113    # competition's scoring rule (``ctfy/server/scoring/``). Under the
1114    # default ``flag_count`` rule it equals ``flags_solved``; under a
1115    # points rule it is the point total. Kept separate from
1116    # ``flags_solved`` so the UI can show both "how much progress" and
1117    # "how many points" without knowing which rule produced the row.
1118    score: int = 0
1119    attempts: int = 0
1120    last_solve_at: datetime | None = None
1121    # Most recent activity of any kind. Distinct from ``last_solve_at`` —
1122    # a team may be actively attempting without a solve, and the UI
1123    # wants to show that they're not idle.
1124    last_active_at: datetime | None = None
1125    # OAuth provider avatar URL for the team's earliest identity that
1126    # supplies one. Empty for password-only teams; the frontend falls
1127    # back to a generated initials avatar in that case.
1128    # Whether a person or an agent is behind this entry. Both compete on
1129    # the same board — a user enters, and a user's agent enters — so a
1130    # board that does not say which is which is one whose ranking cannot
1131    # be read.
1132    #
1133    # ⚠️ A display fact, never a ranking one. No rule scores on it: an
1134    # agent and a human are ranked by what they solved, and separating
1135    # them is the reader's choice to make from a labelled board.
1136    entrant_kind: EntrantKind = "human"
1137    # Whose agent this is, when it is somebody's. ``None`` for a person
1138    # **and** for the platform's own eval entrant — see
1139    # :class:`AgentEntrantInfo`.
1140    #
1141    # ⚠️ Attached after the scoring rule has produced the row, never
1142    # carried through :class:`TeamFacts`. A rule reads only the facts it
1143    # names, so keeping this out of them is what makes "no rule can rank
1144    # on who built the agent" structural rather than a convention four
1145    # rule implementations have to keep.
1146    agent: AgentEntrantInfo | None = None
1147    avatar_url: str = ""
1148    #: The institution **this squad** publishes, or ``None`` — see
1149    #: :attr:`ctfy.server.models.TeamInfo.affiliation`, which is the
1150    #: same fact about the same subject.
1151    #:
1152    #: ⚠️ **A display fact, never a ranking one**, exactly like
1153    #: ``entrant_kind`` and ``agent``: it is attached after the scoring
1154    #: rule has produced the row and never carried through
1155    #: :class:`TeamFacts`, so no rule can rank on where a team studies.
1156    #:
1157    #: ⚠️ **Cleared by ``_board_fields``**, with the avatar and the
1158    #: agent attribution: an institution beside an alias is stronger
1159    #: identity than the name it replaced.
1160    affiliation: AffiliationInfo | None = None
1161    # The AWD+ split of ``score``. Zero on every other format, where the
1162    # board renders one number and these columns stay hidden.
1163    #
1164    # Both are carried even though ``attack + defence == score``, because
1165    # the point of the split *is* the breakdown: a single total never
1166    # explains why a team that solved less is ranked higher. Deriving one
1167    # from the other in the client would put the format's arithmetic in
1168    # two places.
1169    # ⚠️ **Whether the identity on this row is withheld, never whether
1170    # the entrant asked for it.** The client needs to know to render the
1171    # localised word beside the alias and to stop linking; it must not be
1172    # told anything about who is behind it. Defaults ``False``, which is
1173    # what every producer that predates the field means and what an
1174    # older server's rows read as — the safe direction, since a wrong
1175    # ``True`` merely labels a visible team "anonymous" while a wrong
1176    # ``False`` publishes nothing it did not already publish (the name it
1177    # would print is the alias either way).
1178    anonymous: bool = False
1179    attack_score: int = 0
1180    defence_score: int = 0
1181    # Challenges this team holds a ``fixed`` defence verdict on. A count,
1182    # not the ids — the board is a ranking, and the ids are one click
1183    # away on the team's own page.
1184    defended: int = 0
1185    # The exercise-format split of ``score``. Zero on every other format,
1186    # for the same reason and with the same shape as the AWD+ pair above.
1187    #
1188    # ⚠️ Deliberately **not** a reuse of ``attack_score`` /
1189    # ``defence_score``. The numbers happen to compose the same way and
1190    # they are different facts: reusing the AWD names would put "how well
1191    # this team wrote it up" in a field every reader has been told means
1192    # "patches that survived the exploit". A wire field is a claim about
1193    # what the number *is*.
1194    #
1195    # ``reporting_score`` is the graded half weighted onto the capability
1196    # scale, so it is what the board ranked by — the grader's own
1197    # unweighted total stays on ``ExerciseReportGradeState.points``,
1198    # which is the record an appeal argues from.
1199    capability_score: int = 0
1200    reporting_score: int = 0
1201    # The peer-authoring split of ``score``. Zero on every other
1202    # format, same shape and same reason as the two pairs above.
1203    #
1204    # ⚠️ **Its own field rather than a reuse of ``reporting_score``**,
1205    # for the reason stated there about the AWD pair: the numbers
1206    # compose the same way and they are different facts, and a wire
1207    # field is a claim about what the number *is*. "How the field fared
1208    # against the challenges you wrote" in a field readers have been
1209    # told means "how well you wrote up an engagement" is a wrong
1210    # sentence rendered from correct arithmetic.
1211    #
1212    # ⚠️ It is the **weighted** term — what the board ranked by — for
1213    # `reporting_score`'s reason: the unweighted per-challenge curve
1214    # values are not stored anywhere a dispute could argue from, so this
1215    # number has to be the one the ranking used rather than a second
1216    # rendering of it. The solving half sits on ``capability_score``,
1217    # which this format reuses verbatim.
1218    authoring_score: int = 0
1219    # The team's eligibility verdict, and **only when the viewer is
1220    # entitled to it** — a competition admin or reviewer. Everywhere else
1221    # this is ``""``.
1222    #
1223    # ``""`` means "not disclosed", never "approved". Defaulting it to
1224    # ``approved`` would have been the natural-looking choice and is the
1225    # bug: a public board would then assert that every team passed
1226    # review, which is a stronger claim than the platform is entitled to
1227    # make and is false during the review backlog.
1228    #
1229    # Not public because no other surface publishes it either — the
1230    # verdict reaches the team's own members and the organisers, and
1231    # nobody else. Putting "rejected" next to a team name on a page the
1232    # whole event reads is a different act from recording it on the
1233    # review queue.
1234    #
1235    # It never affects ``rank``: review is an eligibility marker, not a
1236    # gate, and an unapproved team plays and places exactly as it would
1237    # have. ``TestReviewIsNotAGate`` pins that; this field is what makes
1238    # the fact *visible* to the organiser without changing it.
1239    review_status: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

rank: int = 0
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
solved: int = 0
flags_solved: int = 0
score: int = 0
attempts: int = 0
last_solve_at: datetime.datetime | None = None
last_active_at: datetime.datetime | None = None
entrant_kind: Literal['human', 'agent'] = 'human'
agent: AgentEntrantInfo | None = None
avatar_url: str = ''
affiliation: AffiliationInfo | None = None
anonymous: bool = False
attack_score: int = 0
defence_score: int = 0
defended: int = 0
capability_score: int = 0
reporting_score: int = 0
authoring_score: int = 0
review_status: str = ''
class SeriesOccurrencePreview(ctfy.core.models.CtfyModel):
148class SeriesOccurrencePreview(CtfyModel):
149    """One occurrence a series *would* create, without creating it."""
150
151    occurrence_at: AwareDatetime
152    title: str = ""
153    challenge_ids: list[str] = Field(default_factory=list)
154    #: Empty when this occurrence could be filled. Otherwise why not —
155    #: the pool ran dry, or names challenges the catalog no longer has.
156    refused: str = ""

One occurrence a series would create, without creating it.

occurrence_at: pydantic.types.AwareDatetime = PydanticUndefined
title: str = ''
challenge_ids: list[str] = PydanticUndefined
refused: str = ''
class SeriesPreview(ctfy.core.models.CtfyModel):
159class SeriesPreview(CtfyModel):
160    """The dry run.
161
162    Its own route rather than a flag on the save, for the same reason
163    `GET /awd/plan` is: the answer is what decides whether to commit. A
164    weekly's first real feedback is otherwise a week away, and the
165    mistakes it catches — wrong weekday, wrong timezone, a pool that
166    covers three weeks of a schedule meant to run indefinitely — are all
167    invisible in the form.
168
169    ⚠️ It projects **past the lead window** on purpose. `plan_spawns`
170    answers "what should exist now", which for a freshly-saved series is
171    usually one contest; an organiser needs to see the *fourth* week to
172    know the pool runs out.
173    """
174
175    series_id: str = ""
176    occurrences: list[SeriesOccurrencePreview] = Field(default_factory=list)
177    #: Occurrences already past, reported for the same reason the job
178    #: reports them: a schedule whose anchor is in the past is a common
179    #: mistake and silently produces nothing.
180    missed: list[AwareDatetime] = Field(default_factory=list)

The dry run.

Its own route rather than a flag on the save, for the same reason GET /awd/plan is: the answer is what decides whether to commit. A weekly's first real feedback is otherwise a week away, and the mistakes it catches — wrong weekday, wrong timezone, a pool that covers three weeks of a schedule meant to run indefinitely — are all invisible in the form.

⚠️ It projects past the lead window on purpose. plan_spawns answers "what should exist now", which for a freshly-saved series is usually one contest; an organiser needs to see the fourth week to know the pool runs out.

series_id: str = ''
occurrences: list[SeriesOccurrencePreview] = PydanticUndefined
missed: list[pydantic.types.AwareDatetime] = PydanticUndefined
class SetPasswordRequest(ctfy.core.models.CtfyModel):
287class SetPasswordRequest(CtfyModel):
288    # Required iff the caller already has a password identity — absent on
289    # the first-time "attach password to an OAuth account" flow.
290    current_password: str = Field(default="", max_length=256)
291    # See RegisterRequest.password for the length-minimum rationale.
292    new_password: str = Field(min_length=12, max_length=256)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

current_password: str = ''
new_password: str = PydanticUndefined
class ShellRecordingInfo(ctfy.core.models.CtfyModel):
526class ShellRecordingInfo(CtfyModel):
527    """One row from ``GET /admin/shell-recordings``: a per-session
528    asciinema v2 cast file the platform wrote when a super-admin
529    opened a docker-exec shell. The frontend renders a playable
530    inline player; the raw bytes are pulled via the per-session
531    download route (also super-admin gated, also audited)."""
532
533    session_id: str = ""
534    path: str = ""
535    size_bytes: int = 0
536    modified_at_ts: float = 0.0

One row from GET /admin/shell-recordings: a per-session asciinema v2 cast file the platform wrote when a super-admin opened a docker-exec shell. The frontend renders a playable inline player; the raw bytes are pulled via the per-session download route (also super-admin gated, also audited).

session_id: str = ''
path: str = ''
size_bytes: int = 0
modified_at_ts: float = 0.0
class SshCredential(ctfy.core.models.CtfyModel):
672class SshCredential(CtfyModel):
673    """Everything a defender needs to `ssh` into their own box, once.
674
675    Model (b): the platform mints the keypair rather than a player
676    registering one, so both halves are here. The private key is
677    returned exactly once and never stored — there is nothing to leak
678    later and nothing to revoke, because it dies with the certificate's
679    five-minute window.
680
681    ⚠️ **The username is not cosmetic.** The bastion reads the subject
682    back off the login name, and asyncssh refuses a certificate whose
683    signed principal list omits it — so a client that connects under any
684    other name is rejected at the handshake with nothing in the response
685    explaining why.
686    """
687
688    #: Which transport this credential is for — see ``AwdShellExposure``.
689    #: ``managed`` (the default) is the cert/bastion shape below; ``open``
690    #: is the classic-AWD box-local ``sshd``, where the fields that carry
691    #: are ``username`` / ``password`` / ``host`` / ``port`` and the cert
692    #: pair is empty. A client branches on this rather than guessing from
693    #: which fields are set (#1725).
694    exposure: str = AwdShellExposure.MANAGED.value
695    #: OpenSSH private key (``managed`` only). Returned once; the platform
696    #: keeps no copy. ``""`` under ``open`` exposure.
697    private_key: str = ""
698    #: The certificate over it, signed by the platform's SSH CA
699    #: (``managed`` only). ``""`` under ``open`` exposure.
700    certificate: str = ""
701    #: The login name. ``user_id:instance_id`` under ``managed`` (the
702    #: bastion reads the subject off it); the box's login user (``root``)
703    #: under ``open``.
704    username: str
705    #: The starting password (``open`` exposure only). ``""`` under
706    #: ``managed``, which authenticates with the cert. The owner rotates
707    #: it with ``appctl passwd`` after logging in.
708    password: str = ""
709    #: Where to present it. Under ``managed`` this is the bastion, which
710    #: only the operator knows how to publish. Under ``open`` it is the
711    #: box's own arena address, reached over the caller's arena tunnel.
712    host: str
713    port: int
714    #: Seconds the credential stays useful for *connecting*. Under
715    #: ``managed`` it is the certificate's short life; under ``open`` the
716    #: password lasts the match (until the defender rotates it), so this
717    #: is the match's remaining seconds as an advisory, not a hard expiry.
718    expires_in: int

Everything a defender needs to ssh into their own box, once.

Model (b): the platform mints the keypair rather than a player registering one, so both halves are here. The private key is returned exactly once and never stored — there is nothing to leak later and nothing to revoke, because it dies with the certificate's five-minute window.

⚠️ The username is not cosmetic. The bastion reads the subject back off the login name, and asyncssh refuses a certificate whose signed principal list omits it — so a client that connects under any other name is rejected at the handshake with nothing in the response explaining why.

exposure: str = 'managed'
private_key: str = ''
certificate: str = ''
username: str = PydanticUndefined
password: str = ''
host: str = PydanticUndefined
port: int = PydanticUndefined
expires_in: int = PydanticUndefined
class SshExchangeRequest(ctfy.core.models.CtfyModel):
652class SshExchangeRequest(CtfyModel):
653    """The subject a bastion has already authenticated by certificate.
654
655    ⚠️ **Not the certificate.** That was the first shape and it cannot
656    be built: asyncssh discards the client certificate once it has
657    validated it, so the bastion has nothing to forward. What survives
658    the handshake is the login name, which the CA signs — a certificate
659    whose principal list omits the requested username is refused — so
660    the bastion knows the subject but cannot re-prove it with the
661    credential. The bearer on this request is what makes the claim
662    trustworthy; see ``routes/ssh_exchange.py``.
663    """
664
665    #: Who the certificate named. Authorisation still runs against them.
666    user_id: str
667    #: Which box. Re-resolved here — it can stop or change hands between
668    #: the certificate mint and the connect.
669    instance_id: str

The subject a bastion has already authenticated by certificate.

⚠️ Not the certificate. That was the first shape and it cannot be built: asyncssh discards the client certificate once it has validated it, so the bastion has nothing to forward. What survives the handshake is the login name, which the CA signs — a certificate whose principal list omits the requested username is refused — so the bastion knows the subject but cannot re-prove it with the credential. The bearer on this request is what makes the claim trustworthy; see routes/ssh_exchange.py.

user_id: str = PydanticUndefined
instance_id: str = PydanticUndefined
class StarGazerVerifyResponse(ctfy.core.models.CtfyModel):
146class StarGazerVerifyResponse(CtfyModel):
147    """Reply for ``POST /me/star-gazer/verify``.
148
149    A 200 OK with ``verified=False`` is the friendly path — the user
150    can be told *why* (no_github_identity / not_starred /
151    rate_limited / lookup_failed / repo_not_configured /
152    limit_exceeded) without parsing an HTTP error. Hard 4xx is
153    reserved for auth.
154    """
155
156    verified: bool
157    # ``"starred"`` on success; one of the StarVerifyResult values +
158    # the route's explicit auxiliary reasons on failure.
159    reason: str = ""
160    achievement: TeamAchievement | None = None
161    already_unlocked: bool = False

Reply for POST /me/star-gazer/verify.

A 200 OK with verified=False is the friendly path — the user can be told why (no_github_identity / not_starred / rate_limited / lookup_failed / repo_not_configured / limit_exceeded) without parsing an HTTP error. Hard 4xx is reserved for auth.

verified: bool = PydanticUndefined
reason: str = ''
achievement: TeamAchievement | None = None
already_unlocked: bool = False
class StartRequest(ctfy.core.models.CtfyModel):
537class StartRequest(CtfyModel):
538    challenge_id: str
539    # Per-instance TTL override in seconds. ``None`` (the default)
540    # defers to the platform's ``default_instance_ttl_s`` setting —
541    # admin-tunable, currently 24h. Explicit values are clamped to
542    # ``max_instance_ttl_s`` so a misbehaving SDK / agent can't pin
543    # a node by requesting an arbitrarily large TTL.
544    ttl: int | None = None
545    # Which competition this instance is for. The platform looks up
546    # the caller's per-comp team via ``(user_id, competition_id)`` and
547    # stamps the instance with that team. Optional only when the
548    # caller is on exactly one team — multi-comp users always pass
549    # it explicitly.
550    competition_id: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

challenge_id: str = PydanticUndefined
ttl: int | None = None
competition_id: str = ''
class StartResponse(ctfy.core.models.CtfyModel):
553class StartResponse(CtfyModel):
554    id: str
555    status: InstanceStatus = InstanceStatus.STARTING

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
status: ctfy.core.enums.InstanceStatus = <InstanceStatus.STARTING: 'starting'>
class StopResponse(ctfy.core.models.CtfyModel):
558class StopResponse(CtfyModel):
559    status: InstanceStatus = InstanceStatus.STOPPED
560    id: str

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

status: ctfy.core.enums.InstanceStatus = <InstanceStatus.STOPPED: 'stopped'>
id: str = PydanticUndefined
class SubmissionCreate(ctfy.core.models.CtfyModel):
887class SubmissionCreate(CtfyModel):
888    # ``instance_id`` is the canonical handle since 0.2: the server
889    # reads challenge_id and competition_id off the instance row, so
890    # callers don't need to repeat them and N instances per
891    # (team, challenge) are unambiguous on submit.
892    instance_id: str
893    # Which question on the challenge the agent is answering. Defaults
894    # to ``"flag"`` so single-question challenges stay one-liner submits;
895    # multi-question challenges (see ``ChallengeInfo.questions``) must
896    # specify the id explicitly.
897    question_id: str = "flag"
898    # The agent's submission. String for ``dynamic`` / ``static`` /
899    # ``single_select`` questions; list of strings for ``multi_select``
900    # (e.g. ``["idor", "ssrf"]``). The server picks the grader by
901    # consulting ``spec.questions[i].mode`` on the live instance.
902    answer: str | list[str]

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

instance_id: str = PydanticUndefined
question_id: str = 'flag'
answer: str | list[str] = PydanticUndefined
class SubmissionResponse(ctfy.core.models.CtfyModel):
905class SubmissionResponse(CtfyModel):
906    id: str
907    correct: bool
908    solve_time_s: float = 0  # seconds from instance start to correct submission
909    # 1 = first blood, 2 = second, 3 = third, 4+ = regular solve.
910    # 0 when incorrect or when the team already had a prior correct solve.
911    # Ranks within the specific question — independent of the
912    # full-challenge solve rank.
913    solve_rank: int = 0
914    # The question id this submission targeted. Echoed back to the
915    # caller so simple submission UIs don't have to thread the id
916    # separately. Equal to the request's ``question_id``.
917    question_id: str | None = None
918    # True iff this team has now captured every question declared on
919    # the challenge. Single-question challenges: always equals ``correct``.
920    challenge_fully_solved: bool = False
921    # Remaining wrong-attempt budget for the question this submission
922    # targeted, AFTER this attempt is counted (the cap is on wrongs;
923    # correct submissions don't consume budget). ``None`` when the
924    # question's mode is uncapped (dynamic free-form, or any mode the
925    # operator opted out of via ``question_attempt_caps``). The UI
926    # uses this to decrement its remaining-attempts badge without
927    # re-fetching the instance — see ``ctfy.server.submission_policy``.
928    attempts_remaining: int | None = None
929    # Whether the team's capture of this question is on the
930    # competition's board. Only meaningful when ``correct``.
931    #
932    # ⚠️ **Defaults to False on purpose**, which is the loud direction.
933    # A competition accepts submissions outside its play window and
934    # simply does not count them (only a competition declaring
935    # ``sessions`` refuses), so before this field the player got
936    # ``correct: true`` and scored nothing, with nothing anywhere
937    # saying why. A producer that forgets to set this makes every solve
938    # claim it did not count — visible in a minute; the other default
939    # restores exactly the silence this exists to end.
940    ranked: bool = False
941    # Why it is not on the board, when it is not.
942    #
943    # ⚠️ **A refinement of ``ranked``, never a second answer to it.** A
944    # client gates on ``ranked``; this only decides *which sentence* it
945    # then shows, because the two members' repairs are opposite —
946    # ``outside_window`` says come back while the competition is open,
947    # and that copy under ``own_authored`` is advice that can never pay
948    # off. ``""`` names no reason at all, which is what an older server
949    # and a forgetful producer both say.
950    unranked_reason: UnrankedReason = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
correct: bool = PydanticUndefined
solve_time_s: float = 0
solve_rank: int = 0
question_id: str | None = None
challenge_fully_solved: bool = False
attempts_remaining: int | None = None
ranked: bool = False
unranked_reason: Literal['', 'outside_window', 'own_authored'] = ''
class TagStat(ctfy.core.models.CtfyModel):
452class TagStat(CtfyModel):
453    """One axis on the per-tag strength radar.
454
455    ``solved`` is the team's solve count for challenges carrying this
456    tag; ``total`` is the platform-wide challenge count for the tag,
457    so the frontend can render "team / total" or normalize to a 0–1
458    ratio for the radar polygon."""
459
460    tag: str = ""
461    solved: int = 0
462    total: int = 0

One axis on the per-tag strength radar.

solved is the team's solve count for challenges carrying this tag; total is the platform-wide challenge count for the tag, so the frontend can render "team / total" or normalize to a 0–1 ratio for the radar polygon.

tag: str = ''
solved: int = 0
total: int = 0
class TeamAchievement(ctfy.core.models.CtfyModel):
80class TeamAchievement(CtfyModel):
81    """A badge a team has unlocked."""
82
83    achievement_id: str
84    name: str
85    description: str
86    icon: str
87    tier: str
88    secret: bool = False
89    #: See :attr:`AchievementCatalogEntry.localized`.
90    localized: dict[str, AchievementCopy] = Field(default_factory=dict)
91    unlocked_at: datetime | None = None
92    context: dict[str, Any] = Field(default_factory=dict)
93    points: int = 0
94    earned_by_count: int = 0
95    rarity: str = "unearned"

A badge a team has unlocked.

achievement_id: str = PydanticUndefined
name: str = PydanticUndefined
description: str = PydanticUndefined
icon: str = PydanticUndefined
tier: str = PydanticUndefined
secret: bool = False
localized: dict[str, AchievementCopy] = PydanticUndefined
unlocked_at: datetime.datetime | None = None
context: dict[str, typing.Any] = PydanticUndefined
points: int = 0
earned_by_count: int = 0
rarity: str = 'unearned'
class TeamAffiliationSelection(ctfy.core.models.CtfyModel):
124class TeamAffiliationSelection(CtfyModel):
125    """A squad's badge: the choice, its effect, and what it could be.
126
127    ⚠️ **Three fields because two of them can disagree, and the
128    disagreement is the state a captain needs to see.**
129    :attr:`domain` is what the team chose and :attr:`affiliation` what
130    strangers actually see — a badge whose backing has gone (the member
131    who published it left, stopped publishing, or the operator removed
132    the catalogue row) reads as *chosen, showing nothing*, which a model
133    holding only the badge cannot tell from *nothing chosen* and which
134    has a different repair. `AffiliationSelection`'s decision on the
135    personal badge, for the same reason.
136
137    ⚠️ **:attr:`choices` is on the answer rather than a second route**,
138    because the two are one question with one cost: resolving the badge
139    already reads every member's published institution, so a picker
140    fetched separately would pay for that walk twice and could show a
141    captain an option the write then refuses.
142
143    ⚠️ **Published, never merely proved.** ``choices`` holds only what
144    team-mates have chosen to show on their own profiles — see
145    `ctfy.server.affiliations.team_candidates`. A squad whose members
146    all stay private can claim nothing, which is the correct answer
147    rather than a gap.
148    """
149
150    #: The domain the captain chose. ``""`` is *no badge*, the default
151    #: and the opt-out.
152    domain: str = ""
153    #: What that choice resolves to right now, or ``None``.
154    affiliation: AffiliationInfo | None = None
155    #: Every institution at least one current member publishes — the
156    #: set the choice is checked against on every read, which is why it
157    #: is the same set the picker offers.
158    choices: list[AffiliationInfo] = Field(default_factory=list)

A squad's badge: the choice, its effect, and what it could be.

⚠️ Three fields because two of them can disagree, and the disagreement is the state a captain needs to see. domain is what the team chose and affiliation what strangers actually see — a badge whose backing has gone (the member who published it left, stopped publishing, or the operator removed the catalogue row) reads as chosen, showing nothing, which a model holding only the badge cannot tell from nothing chosen and which has a different repair. AffiliationSelection's decision on the personal badge, for the same reason.

⚠️ choices is on the answer rather than a second route, because the two are one question with one cost: resolving the badge already reads every member's published institution, so a picker fetched separately would pay for that walk twice and could show a captain an option the write then refuses.

⚠️ Published, never merely proved. choices holds only what team-mates have chosen to show on their own profiles — see ctfy.server.affiliations.team_candidates. A squad whose members all stay private can claim nothing, which is the correct answer rather than a gap.

domain: str = ''
affiliation: AffiliationInfo | None = None
choices: list[AffiliationInfo] = PydanticUndefined
class TeamCaptainRequest(ctfy.core.models.CtfyModel):
377class TeamCaptainRequest(CtfyModel):
378    """Body for an organiser handing a team to one of its members."""
379
380    # The member to promote. Must already be on the team — promoting an
381    # outsider would be a join and a transfer in one call, which is how
382    # a squad ends up with a captain nobody on it recognises.
383    user_id: str = Field(min_length=1)

Body for an organiser handing a team to one of its members.

user_id: str = PydanticUndefined
class TeamDetail(ctfy.server.models.TeamInfo):
104class TeamDetail(TeamInfo):
105    """``GET /teams/{id}`` payload — adds the resolved members list."""
106
107    members: list[TeamMemberInfo] = Field(default_factory=list)

GET /teams/{id} payload — adds the resolved members list.

members: list[TeamMemberInfo] = PydanticUndefined
class TeamDisqualifyRequest(ctfy.core.models.CtfyModel):
334class TeamDisqualifyRequest(CtfyModel):
335    """Body for an organiser removing a team from a competition."""
336
337    # Mandatory, and with a floor rather than merely non-empty. This is
338    # the row an appeal is argued from and the text the team is shown
339    # when their next submission is refused; "cheating" tells them
340    # nothing they can answer. Same reasoning as the admin shell's
341    # mandatory ≥10-char reason, which exists for the same audience.
342    reason: str = Field(min_length=10, max_length=2000)

Body for an organiser removing a team from a competition.

reason: str = PydanticUndefined
class TeamInfo(ctfy.core.models.CtfyModel):
21class TeamInfo(CtfyModel):
22    """A team's public-facing summary.
23
24    Post user/team-split a team is a pure container — no email, no
25    role, no human profile fields. ``solves`` and ``attempts`` aggregate
26    across every member's contribution while they were on this team.
27    """
28
29    id: str
30    name: str
31    description: str = ""
32    captain_user_id: str = ""
33    captain_display_name: str = ""
34    captain_avatar_url: str = ""
35    member_count: int = 0
36    created_at: datetime | None = None
37    solves: int = 0
38    attempts: int = 0
39    last_active_at: datetime | None = None
40    # Per-competition scoping — every team belongs to exactly one
41    # competition.
42    competition_id: str = ""
43    #: See :attr:`ctfy.server.models.ScoreboardEntry.anonymous`.
44    #:
45    #: ⚠️ ``solves`` / ``attempts`` / ``member_count`` stay on a withheld
46    #: row and are not the leak they look like. The de-anonymising join
47    #: this route made possible was *name × solve count* against the
48    #: board; with the name withheld on both sides there is nothing to
49    #: join to, and a bare progress count names nobody. Withholding them
50    #: as well would make an anonymous entrant's card a row of dashes
51    #: for no gain.
52    anonymous: bool = False
53    #: The institution **this squad** publishes, or ``None``.
54    #:
55    #: ⚠️ **The team's, never a member's, and the field name is the
56    #: whole hazard.** A person's badge is
57    #: :attr:`ctfy.server.models.UserInfo.affiliation` and derives from
58    #: ``users.affiliation_email``; this one derives from
59    #: ``teams.affiliation_domain``. Two subjects, one word — so a
60    #: surface that renders "the affiliation" without saying whose is
61    #: how a captain's university ends up printed against a squad that
62    #: never chose it. :data:`ctfy.server.models.TeamMemberInfo` carries
63    #: none for exactly that reason.
64    #:
65    #: ⚠️ **Composed, never stored resolved**, the same as the personal
66    #: badge: the domain is on the row and the institution is looked up
67    #: on every read, so the badge vanishes when the operator deletes
68    #: the catalogue entry or the last member who published it leaves.
69    #:
70    #: ⚠️ **Cleared under an alias.** An institution beside a
71    #: pseudonym is stronger identity than the name it replaced — "the
72    #: Tsinghua entrant" narrows a field of two hundred to a handful —
73    #: so `ctfy.server.anonymity` blanks it wherever it blanks the name;
74    #: `test_the_badge_never_reaches_an_alias` drives every applier that
75    #: reaches this model and asserts so.
76    affiliation: AffiliationInfo | None = None

A team's public-facing summary.

Post user/team-split a team is a pure container — no email, no role, no human profile fields. solves and attempts aggregate across every member's contribution while they were on this team.

id: str = PydanticUndefined
name: str = PydanticUndefined
description: str = ''
captain_user_id: str = ''
captain_display_name: str = ''
captain_avatar_url: str = ''
member_count: int = 0
created_at: datetime.datetime | None = None
solves: int = 0
attempts: int = 0
last_active_at: datetime.datetime | None = None
competition_id: str = ''
anonymous: bool = False
affiliation: AffiliationInfo | None = None
class TeamInviteInfo(ctfy.core.models.CtfyModel):
308class TeamInviteInfo(CtfyModel):
309    """Public projection of a :class:`TeamInviteState`.
310
311    Surfaces on captain CRUD. ``active`` is server-derived from the
312    expires/use/revoked triple. ``kind`` discriminates code-style
313    invites from directed invites/requests; ``target_user_id`` is
314    the recipient for ``kind="direct"`` (set at mint time) and the
315    requester for ``kind="join_request"`` (set when a member opens
316    a request).
317    """
318
319    id: str
320    code: str
321    team_id: str
322    created_by_user_id: str
323    created_at: datetime | None = None
324    expires_at: datetime | None = None
325    max_uses: int
326    use_count: int
327    revoked_at: datetime | None = None
328    active: bool = True
329    competition_id: str = ""
330    kind: Literal["code", "direct", "join_request"] = "code"
331    target_user_id: str = ""

Public projection of a TeamInviteState.

Surfaces on captain CRUD. active is server-derived from the expires/use/revoked triple. kind discriminates code-style invites from directed invites/requests; target_user_id is the recipient for kind="direct" (set at mint time) and the requester for kind="join_request" (set when a member opens a request).

id: str = PydanticUndefined
code: str = PydanticUndefined
team_id: str = PydanticUndefined
created_by_user_id: str = PydanticUndefined
created_at: datetime.datetime | None = None
expires_at: datetime.datetime | None = None
max_uses: int = PydanticUndefined
use_count: int = PydanticUndefined
revoked_at: datetime.datetime | None = None
active: bool = True
competition_id: str = ''
kind: Literal['code', 'direct', 'join_request'] = 'code'
target_user_id: str = ''
class TeamLogoUploadResponse(ctfy.core.models.CtfyModel):
297class TeamLogoUploadResponse(CtfyModel):
298    """``POST /competitions/{id}/team/logo`` result."""
299
300    object_key: str
301    content_type: str = ""
302    size_bytes: int = 0

POST /competitions/{id}/team/logo result.

object_key: str = PydanticUndefined
content_type: str = ''
size_bytes: int = 0
class TeamMemberInfo(ctfy.core.models.CtfyModel):
 79class TeamMemberInfo(CtfyModel):
 80    """One member row on the team-detail page."""
 81
 82    user_id: str
 83    display_name: str = ""
 84    avatar_url: str = ""
 85    is_captain: bool = False
 86    joined_at: datetime | None = None
 87    #: This member's *own* name is withheld — either because they asked
 88    #: or because the squad around them is anonymous. See
 89    #: :attr:`ctfy.server.models.ScoreboardEntry.anonymous`.
 90    anonymous: bool = False
 91
 92    # ⚠️ **No ``affiliation`` here, and it is a decision rather than an
 93    # omission.** A roster row is a *person* inside a *team*, so a badge
 94    # on it would be the person's while every alias around it is decided
 95    # about the squad: ``withholds_team`` rules on the row this sits in,
 96    # ``withholds_user`` on the row itself, and ``_is_own_team`` is a
 97    # third predicate that shows a captain their own members' identities.
 98    # A per-person attribute one forgotten branch from a withheld squad
 99    # is the shape this repository has paid for; the surfaces the badge
100    # was asked for are the team's, and `TeamInfo.affiliation` carries
101    # them. `test_the_badge_never_reaches_an_alias` refuses it by name.

One member row on the team-detail page.

user_id: str = PydanticUndefined
display_name: str = ''
avatar_url: str = ''
is_captain: bool = False
joined_at: datetime.datetime | None = None
anonymous: bool = False
class TeamStandingInfo(ctfy.core.models.CtfyModel):
345class TeamStandingInfo(CtfyModel):
346    """Whether a team is currently in the event, and why not.
347
348    A separate model from ``TeamDetail`` on purpose: this is what the
349    two enforcement routes return and what the admin table polls, so it
350    stays small enough to fetch per row without dragging a roster and a
351    logo key along.
352    """
353
354    id: str
355    name: str
356    competition_id: str = ""
357    disqualified: bool = False
358    disqualified_at: datetime | None = None
359    disqualified_by: str = ""
360    disqualification_reason: str = ""
361
362    @classmethod
363    def from_team(cls, team: TeamState) -> TeamStandingInfo:
364        return cls(
365            id=team.id,
366            name=team.name,
367            competition_id=team.competition_id,
368            # Projected as a plain bool so a client never has to know
369            # that "absent timestamp" is how the platform spells "in".
370            disqualified=team.is_disqualified,
371            disqualified_at=team.disqualified_at,
372            disqualified_by=team.disqualified_by,
373            disqualification_reason=team.disqualification_reason,
374        )

Whether a team is currently in the event, and why not.

A separate model from TeamDetail on purpose: this is what the two enforcement routes return and what the admin table polls, so it stays small enough to fetch per row without dragging a roster and a logo key along.

id: str = PydanticUndefined
name: str = PydanticUndefined
competition_id: str = ''
disqualified: bool = False
disqualified_at: datetime.datetime | None = None
disqualified_by: str = ''
disqualification_reason: str = ''
@classmethod
def from_team( cls, team: ctfy.core.state.models.TeamState) -> TeamStandingInfo:
362    @classmethod
363    def from_team(cls, team: TeamState) -> TeamStandingInfo:
364        return cls(
365            id=team.id,
366            name=team.name,
367            competition_id=team.competition_id,
368            # Projected as a plain bool so a client never has to know
369            # that "absent timestamp" is how the platform spells "in".
370            disqualified=team.is_disqualified,
371            disqualified_at=team.disqualified_at,
372            disqualified_by=team.disqualified_by,
373            disqualification_reason=team.disqualification_reason,
374        )
class TestEmailRequest(ctfy.core.models.CtfyModel):
111class TestEmailRequest(CtfyModel):
112    template: str
113    #: Defaults to the caller's own address. An arbitrary recipient is
114    #: allowed on purpose — checking how a template lands in Outlook or
115    #: QQ mail requires mailing an account on that provider — and every
116    #: send is audited with the address.
117    to: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

template: str = PydanticUndefined
to: str = ''
class TestEmailResult(ctfy.core.models.CtfyModel):
120class TestEmailResult(CtfyModel):
121    sent: bool
122    to: str
123    template: str
124    subject: str
125    #: The provider's id for the accepted message, for cross-referencing
126    #: its delivery dashboard. Empty when the send failed, and also
127    #: legitimately empty on success if the response could not be parsed
128    #: — ``sent`` is the outcome, this is only a handle on it.
129    message_id: str = ""
130    #: Provider-side reason when ``sent`` is false. Empty on success.
131    error: str = ""

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

sent: bool = PydanticUndefined
to: str = PydanticUndefined
template: str = PydanticUndefined
subject: str = PydanticUndefined
message_id: str = ''
error: str = ''
class TokenInfo(ctfy.core.models.CtfyModel):
295class TokenInfo(CtfyModel):
296    id: str
297    #: ⚠️ ``agent`` is deprecated and ``0006`` rewrote every row, but it
298    #: stays a member: a legacy dump restored into a new deployment still
299    #: carries it, and a wire model that refuses a value the column can
300    #: hold turns a readable row into a 500.
301    kind: TokenKind
302    label: str = ""
303    created_at: datetime | None = None
304    last_used_at: datetime | None = None
305    # ISO-8601 UTC timestamp; empty means the token never expires.
306    expires_at: datetime | None = None
307    # Client metadata captured when the session was minted. Only populated
308    # for user-kind tokens (browser sessions).
309    ip_address: str = ""
310    user_agent: str = ""
311    # True for the session that made this request — lets the UI show "this
312    # session" and disable the Revoke button so a user can't accidentally
313    # log themselves out.
314    is_current: bool = False
315    # Fine-grained scope summary (empty/"none" for user-kind tokens).
316    competition_access: CompetitionAccessKind = "none"
317    competition_ids: list[str] = Field(default_factory=list)
318    #: Category → level, dumped from :class:`TokenPermissions`, whose
319    #: every field is a ``PermissionLevel``.
320    #:
321    #: ⚠️ **`dict[str, str]` here was the `max_level` shape a third
322    #: time.** The producer is `TokenPermissions.model_dump()`, so the
323    #: values have never been anything but the three-member ladder —
324    #: while the wire published plain `string` and `api.ts` declared
325    #: `Record<string, PermissionLevel>` anyway, i.e. the client
326    #: asserting more than the server promised, about the value every
327    #: scope gate ranks with. Narrowed here so the derivation in
328    #: `@/types` keeps the check rather than losing it.
329    permissions: dict[str, PermissionLevel] = Field(default_factory=dict)
330
331    #: The id of the token this one replaced, or ``""`` for one that was
332    #: minted rather than rotated.
333    #:
334    #: ⚠️ **A column with no reader is an edge nobody walks.** The
335    #: rotation link shipped as ``team_tokens.rotated_from`` with a
336    #: writer and nothing that reads it — so "revoked rows are kept
337    #: precisely so an audit can resolve a ``pf_…`` found in a log" was
338    #: true of the table and unreachable from every surface. This is
339    #: half the repair (the owner's own list); ``POST
340    #: /admin/token-trace`` is the other.
341    #:
342    #: ⚠️ Named for the column and **not** ``rotated_from_id``: the
343    #: ``<entity>_id``-for-a-foreign-key half of the wire-naming rule is
344    #: about a *different entity's* id, and this is the same table —
345    #: ``CompetitionState.virtual_of`` is the shape it follows.
346    rotated_from: str = ""
347
348    #: Whether this token's grants are exactly one of the named
349    #: templates, or ``""`` for a hand-assembled set.
350    #:
351    #: ⚠️ **A ``computed_field``, deliberately not a column.** Which
352    #: template minted a token is a fact that stops being true the
353    #: moment either the token or the catalog is edited; the permission
354    #: map is the authority, so this re-derives from the map every read
355    #: and cannot disagree with it. (``LlmBudgetRow.exhausted`` is the
356    #: same call on a wire model; ``TeamState.is_disqualified`` is the
357    #: opposite one on a state model, where ``model_dump`` feeds
358    #: ``to_orm`` and every dumped key must be a column.)
359    @computed_field
360    @property
361    def template(self) -> TokenTemplateName | Literal[""]:
362        from ctfy.server.token_templates import template_matching
363
364        return template_matching(self.permissions)

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

id: str = PydanticUndefined
kind: Literal['user', 'agent', 'fine_grained'] = PydanticUndefined
label: str = ''
created_at: datetime.datetime | None = None
last_used_at: datetime.datetime | None = None
expires_at: datetime.datetime | None = None
ip_address: str = ''
user_agent: str = ''
is_current: bool = False
competition_access: Literal['none', 'all', 'selected'] = 'none'
competition_ids: list[str] = PydanticUndefined
permissions: dict[str, typing.Literal['none', 'read', 'write']] = PydanticUndefined
rotated_from: str = ''
template: Union[Literal['agent_play', 'agent_author', 'ci_submit', 'read_only', 'registry_push', 'full_access'], Literal['']]
359    @computed_field
360    @property
361    def template(self) -> TokenTemplateName | Literal[""]:
362        from ctfy.server.token_templates import template_matching
363
364        return template_matching(self.permissions)
class TokenLifetimeRequest(ctfy.core.models.CtfyModel):
498class TokenLifetimeRequest(CtfyModel):
499    """Body for ``renew`` and ``rotate`` — the new lifetime, in days.
500
501    ⚠️ **Measured from now, never added to what is left.** Adding to the
502    remaining time lets a chain of renewals walk a credential's expiry
503    arbitrarily far out while every individual step looks like a
504    30-day one — and "expires in 30 days" is the sentence the page
505    prints, so the drift would be invisible on the only surface that
506    reports it. ``None`` takes the server default, ``0`` is *never
507    expires*: the same encoding ``expires_in_days`` already uses at
508    mint, because two spellings of "no expiry" on one axis is how the
509    expiry picker shipped three answers to one question.
510    """
511
512    expires_in_days: int | None = Field(default=None, ge=0, le=365 * 5)

Body for renew and rotate — the new lifetime, in days.

⚠️ Measured from now, never added to what is left. Adding to the remaining time lets a chain of renewals walk a credential's expiry arbitrarily far out while every individual step looks like a 30-day one — and "expires in 30 days" is the sentence the page prints, so the drift would be invisible on the only surface that reports it. None takes the server default, 0 is never expires: the same encoding expires_in_days already uses at mint, because two spellings of "no expiry" on one axis is how the expiry picker shipped three answers to one question.

expires_in_days: int | None = None
class TokenScopeUpdate(ctfy.core.models.CtfyModel):
464class TokenScopeUpdate(CtfyModel):
465    """Body for ``PATCH /auth/tokens/{id}`` — edit a live token's reach.
466
467    ⚠️ **A partial update, read through ``model_fields_set``.** Every
468    field defaults to ``None`` and ``None`` means *leave it alone*, so
469    a client that sends only ``permissions`` does not blank the
470    competition scope. Reading the whole model instead is the defect
471    the registration editor already paid for: it blanked five columns
472    and landed an audit row naming every field.
473
474    ⚠️ **``competition_ids`` therefore has two absences that are not the
475    same.** ``None`` is *leave the list alone*; ``[]`` is *clear it*,
476    which is what a caller switching to ``none``/``all`` means. Folding
477    them together makes one of the two unsayable.
478
479    ⚠️ **Not here on purpose**: the secret (that is ``rotate``), the
480    expiry (that is ``renew``) and the kind. Each has its own verb with
481    its own refusals, and a PATCH that quietly did any of them would be
482    a fourth way to reach a decision the other three exist to make
483    deliberate.
484    """
485
486    label: str | None = Field(default=None, max_length=64)
487    #: Re-seed from a named template. Explicit fields below still win,
488    #: exactly as at the mint.
489    template: TokenTemplateName | None = None
490    competition_access: str | None = None
491    competition_ids: list[str] | None = None
492    #: ``str``-typed for ``CreateFineGrainedTokenRequest.permissions``'
493    #: reason: this is a request and ``clamp_permissions`` exists to
494    #: lower what arrives rather than refuse it.
495    permissions: dict[str, str] | None = None

Body for PATCH /auth/tokens/{id} — edit a live token's reach.

⚠️ A partial update, read through model_fields_set. Every field defaults to None and None means leave it alone, so a client that sends only permissions does not blank the competition scope. Reading the whole model instead is the defect the registration editor already paid for: it blanked five columns and landed an audit row naming every field.

⚠️ competition_ids therefore has two absences that are not the same. None is leave the list alone; [] is clear it, which is what a caller switching to none/all means. Folding them together makes one of the two unsayable.

⚠️ Not here on purpose: the secret (that is rotate), the expiry (that is renew) and the kind. Each has its own verb with its own refusals, and a PATCH that quietly did any of them would be a fourth way to reach a decision the other three exist to make deliberate.

label: str | None = None
template: Optional[Literal['agent_play', 'agent_author', 'ci_submit', 'read_only', 'registry_push', 'full_access']] = None
competition_access: str | None = None
competition_ids: list[str] | None = None
permissions: dict[str, str] | None = None
class TokenScopesResponse(ctfy.core.models.CtfyModel):
436class TokenScopesResponse(CtfyModel):
437    """``GET /auth/tokens/scopes`` — the catalog the UI renders."""
438
439    categories: list[ScopeCatalogEntry] = Field(default_factory=list)
440    #: Every assignable level, published so the picker need not hardcode
441    #: the taxonomy — which is what this route's docstring promises.
442    #:
443    #: ⚠️ **Derived from the type, and it was a hand-written default on a
444    #: ``list[str]``.** Two things could drift from each other with
445    #: nothing red: the value here and ``PermissionLevel`` itself, and
446    #: this field's ``str`` against ``frontend/src/api.ts``, which
447    #: already declares the same field ``PermissionLevel[]`` — the wire
448    #: was the looser of the two, so the client's stricter claim rested
449    #: on a promise the server did not make.
450    levels: list[PermissionLevel] = Field(default_factory=lambda: list(get_args(PermissionLevel)))

GET /auth/tokens/scopes — the catalog the UI renders.

categories: list[ScopeCatalogEntry] = PydanticUndefined
levels: list[typing.Literal['none', 'read', 'write']] = PydanticUndefined
class TokenTemplatesResponse(ctfy.core.models.CtfyModel):
453class TokenTemplatesResponse(CtfyModel):
454    """``GET /auth/tokens/templates`` — the named starting points.
455
456    Carries the catalog's own models rather than a copy of them: the
457    route publishes exactly what ``POST /auth/tokens`` will mint from,
458    so the picker cannot show a grant the mint does not make.
459    """
460
461    templates: list[TokenTemplateInfo] = Field(default_factory=list)

GET /auth/tokens/templates — the named starting points.

Carries the catalog's own models rather than a copy of them: the route publishes exactly what POST /auth/tokens will mint from, so the picker cannot show a grant the mint does not make.

templates: list[ctfy.server.token_templates.TokenTemplateInfo] = PydanticUndefined
class TokenTraceRequest(ctfy.core.models.CtfyModel):
515class TokenTraceRequest(CtfyModel):
516    """Body for ``POST /admin/token-trace``.
517
518    ⚠️ **The secret travels in the body, never a query parameter** —
519    ``AnswerTraceRequest``'s reason, on a value that is strictly worse
520    to leak: a live bearer in a URL lands in every access log, proxy
521    cache and browser history between the operator and the app, which
522    is the exact accident this route exists to clean up after.
523    """
524
525    token: str = Field(min_length=1, max_length=512)

Body for POST /admin/token-trace.

⚠️ The secret travels in the body, never a query parameter — AnswerTraceRequest's reason, on a value that is strictly worse to leak: a live bearer in a URL lands in every access log, proxy cache and browser history between the operator and the app, which is the exact accident this route exists to clean up after.

token: str = PydanticUndefined
class TokenTraceResponse(ctfy.core.models.CtfyModel):
562class TokenTraceResponse(CtfyModel):
563    """Result of ``POST /admin/token-trace``.
564
565    ⚠️ **A miss is ``found: false`` at 200, never a 404.** "That string
566    is not a token this deployment ever minted" is the answer the
567    operator asked for — it is what clears a scare — while a 404 reads
568    as *the route is wrong* and sends them to check the URL. The
569    registry's don't-leak-existence rule does not transfer: the caller
570    is a super-admin who already holds every token row.
571
572    ``chain`` is the whole rotation lineage, oldest first, so the reply
573    to *"this secret is in a paste bin"* carries the thing the operator
574    needs next — which token is live now. A token that was never
575    rotated yields a chain of one.
576    """
577
578    found: bool = False
579    #: The traced row's id, or ``""``. Present beside ``chain`` because
580    #: the id is what ``TokensPage`` prints and what a revoke names.
581    token_id: str = ""
582    owner_user_id: str = ""
583    owner_display_name: str = ""
584    chain: list[TokenTraceLink] = Field(default_factory=list)

Result of POST /admin/token-trace.

⚠️ A miss is found: false at 200, never a 404. "That string is not a token this deployment ever minted" is the answer the operator asked for — it is what clears a scare — while a 404 reads as the route is wrong and sends them to check the URL. The registry's don't-leak-existence rule does not transfer: the caller is a super-admin who already holds every token row.

chain is the whole rotation lineage, oldest first, so the reply to "this secret is in a paste bin" carries the thing the operator needs next — which token is live now. A token that was never rotated yields a chain of one.

found: bool = False
token_id: str = ''
owner_user_id: str = ''
owner_display_name: str = ''
chain: list[TokenTraceLink] = PydanticUndefined
class TrafficWitness(ctfy.core.models.CtfyModel):
442class TrafficWitness(CtfyModel):
443    """One address seen on a box, and the second it was seen in.
444
445    §7.1's correlation asks a gamebox's capture *who touched it*, and
446    this is that answer reduced to what the question needs. The whole
447    flow — both bodies, ~100 kB apiece — never crosses the wire for it.
448
449    ⚠️ ``second`` is ``""`` when the capture carried no timestamp, and
450    a reader must treat that as **every round**, never as epoch. It is
451    still evidence somebody was there; the only thing it cannot do is
452    say which round, so it may clear a capture and may not place one.
453    """
454
455    ip: str = ""
456    second: str = ""

One address seen on a box, and the second it was seen in.

§7.1's correlation asks a gamebox's capture who touched it, and this is that answer reduced to what the question needs. The whole flow — both bodies, ~100 kB apiece — never crosses the wire for it.

⚠️ second is "" when the capture carried no timestamp, and a reader must treat that as every round, never as epoch. It is still evidence somebody was there; the only thing it cannot do is say which round, so it may clear a capture and may not place one.

ip: str = ''
second: str = ''
class TrendPoint(ctfy.core.models.CtfyModel):
471class TrendPoint(CtfyModel):
472    """One UTC-day bucket on the solve-trend line."""
473
474    date: str = ""  # YYYY-MM-DD, UTC
475    solves: int = 0

One UTC-day bucket on the solve-trend line.

date: str = ''
solves: int = 0
class UpdateUserRoleRequest(ctfy.core.models.CtfyModel):
286class UpdateUserRoleRequest(CtfyModel):
287    """Body for ``PATCH /admin/users/{user_id}/role``.
288
289    Only ``"admin"`` and ``"user"`` are accepted — granting
290    ``"super_admin"`` is reserved for the env allowlist
291    (``CTFY_SUPER_ADMIN_EMAILS``) so the trust anchor stays out-of-band.
292    """
293
294    role: Literal["admin", "user"]

Body for PATCH /admin/users/{user_id}/role.

Only "admin" and "user" are accepted — granting "super_admin" is reserved for the env allowlist (CTFY_SUPER_ADMIN_EMAILS) so the trust anchor stays out-of-band.

role: Literal['admin', 'user'] = PydanticUndefined
class UpdateUserStandingsRequest(ctfy.core.models.CtfyModel):
274class UpdateUserStandingsRequest(CtfyModel):
275    """Body for ``PATCH /admin/users/{user_id}/standings``.
276
277    ``excluded`` is the *decision*, not a timestamp: the caller says
278    whether the account counts, and the server stamps when. A body
279    carrying the moment would let two admins disagree about it and would
280    make "undo" mean writing a value rather than clearing one.
281    """
282
283    excluded: bool

Body for PATCH /admin/users/{user_id}/standings.

excluded is the decision, not a timestamp: the caller says whether the account counts, and the server stamps when. A body carrying the moment would let two admins disagree about it and would make "undo" mean writing a value rather than clearing one.

excluded: bool = PydanticUndefined
class UserEmailInfo(ctfy.core.models.CtfyModel):
178class UserEmailInfo(CtfyModel):
179    """One address on the calling account, as `/me/emails` publishes it.
180
181    ⚠️ **``is_primary`` is a ``computed_field`` composed by the route
182    from ``users.email``, never a stored column.** There is no such
183    column: the primary *is* whichever address ``users.email`` names, so
184    a stored flag would be a second claim about the same fact, free to
185    say two addresses are primary or none is. The route knows the
186    account, so it can answer it; the table cannot be wrong about it.
187
188    ⚠️ **``verified`` mirrors ``EmailVerificationStatus`` rather than
189    inventing a second spelling** — one fact, published in the shape a
190    client reads, and never able to disagree with the timestamp beside
191    it.
192    """
193
194    email: str = ""
195    verified_at: AwareDatetime | None = None
196    #: ``""`` = the account proved this itself; a user id = a super-admin
197    #: vouched for it. Published because "verified" means two different
198    #: things to an operator reading a roster, and the account is
199    #: entitled to know which one its own row carries.
200    verified_by: str = ""
201    added_at: AwareDatetime | None = None
202    primary: bool = False
203
204    @computed_field
205    @property
206    def verified(self) -> bool:
207        return self.verified_at is not None

One address on the calling account, as /me/emails publishes it.

⚠️ is_primary is a computed_field composed by the route from users.email, never a stored column. There is no such column: the primary is whichever address users.email names, so a stored flag would be a second claim about the same fact, free to say two addresses are primary or none is. The route knows the account, so it can answer it; the table cannot be wrong about it.

⚠️ verified mirrors EmailVerificationStatus rather than inventing a second spelling — one fact, published in the shape a client reads, and never able to disagree with the timestamp beside it.

email: str = ''
verified_at: pydantic.types.AwareDatetime | None = None
verified_by: str = ''
added_at: pydantic.types.AwareDatetime | None = None
primary: bool = False
verified: bool
204    @computed_field
205    @property
206    def verified(self) -> bool:
207        return self.verified_at is not None
class UserInfo(ctfy.core.models.CtfyModel):
19class UserInfo(CtfyModel):
20    """A user's public-facing profile."""
21
22    id: str
23    display_name: str = ""
24    avatar_url: str = ""
25    created_at: datetime | None = None
26    bio: str | None = None
27    country: str | None = None
28    website_url: str | None = None
29    timezone: str | None = None
30    social_links: dict[str, str] = Field(default_factory=dict)
31    # Lifetime totals across every team the user has ever been on.
32    solves: int = 0
33    attempts: int = 0
34    last_active_at: datetime | None = None
35    # A person, somebody's agent, or the platform itself — the
36    # user-level axis, one value wider than the team-level
37    # ``ScoreboardEntry.entrant_kind`` (a service never competes, so
38    # the board type cannot say it). The profile route keeps answering
39    # 200 for non-humans (activity rows link every ``team``-actor to
40    # ``/users/{id}``, so a 404 would cut those links) and this field is
41    # what lets the page say what it is showing instead of dressing an
42    # agent — or the platform — as a person. Defaults to ``human`` so an
43    # older server never paints non-human chrome on a person.
44    entrant_kind: UserKind = "human"
45    #: The institution this account publishes, or ``None``.
46    #:
47    #: ⚠️ **``None`` is the default and means *no badge*, which is the
48    #: whole decision rather than an absent value.** Deriving one from
49    #: any proved address would retroactively publish a fact about every
50    #: account that has ever verified one — including everybody who
51    #: attaches a university address this month *because* the feature
52    #: exists. The person proved the mailbox to the platform; publishing
53    #: an inference from it is a second act, and silence here is the
54    #: honest statement that they have not taken it.
55    #:
56    #: ⚠️ **Composed, never a column.** It is resolved on every read
57    #: through ``user_emails`` and the operator's catalogue
58    #: (`ctfy.server.affiliations`), so it disappears the moment the
59    #: address is removed, stops being verified, is handed to another
60    #: account, or the operator deletes the row — with no backfill and
61    #: no cleanup pass.
62    affiliation: AffiliationInfo | None = None

A user's public-facing profile.

id: str = PydanticUndefined
display_name: str = ''
avatar_url: str = ''
created_at: datetime.datetime | None = None
bio: str | None = None
country: str | None = None
website_url: str | None = None
timezone: str | None = None
solves: int = 0
attempts: int = 0
last_active_at: datetime.datetime | None = None
entrant_kind: Literal['human', 'agent', 'service'] = 'human'
affiliation: AffiliationInfo | None = None
class UserScoreboardEntry(ctfy.core.models.CtfyModel):
1373class UserScoreboardEntry(CtfyModel):
1374    """One row on the user-ranked global scoreboard.
1375
1376    Aggregates a single user's solves across every team they've
1377    ever been on (each ``SolveState`` carries both ``user_id`` and
1378    ``team_id``, so per-user totals are a straight filter over the
1379    global solve table). Excludes users with zero solves to keep
1380    the public list compact.
1381    """
1382
1383    rank: int = 0
1384    user_id: str
1385    display_name: str
1386    avatar_url: str = ""
1387    country: str = ""
1388    # Total flags solved across all teams the user has ever been on.
1389    flags_solved: int = 0
1390    # Challenges fully solved (every declared flag captured).
1391    solved: int = 0
1392    last_solve_at: datetime | None = None
1393    #: The institution this person publishes, or ``None``.
1394    #:
1395    #: ⚠️ **On the *people* board and deliberately not on
1396    #: :class:`ScoreboardEntry`.** A team is not an institution: a squad
1397    #: of five holds five affiliations and picking a majority invents a
1398    #: claim nobody made — the ``Standing.player_id`` trap, an id
1399    #: standing in for the wrong identity. A team's own affiliation is a
1400    #: captain-set field backed by a member's proved address, which is a
1401    #: different feature.
1402    #:
1403    #: ⚠️ **And that absence is what keeps anonymity out of this.** Every
1404    #: applier in `ctfy.server.anonymity` publishes an *alias*, and a
1405    #: badge beside an alias is not pseudonymity — "the Tsinghua entrant"
1406    #: narrows a field of two hundred to a handful. This board aliases
1407    #: nobody (it is a career aggregate under real names, and
1408    #: `WithheldSittings` drops a withheld sitting's captures from the
1409    #: totals instead), so the badge here discloses nothing the row's own
1410    #: ``display_name`` does not. ``test_the_badge_never_reaches_an_alias``
1411    #: is what stops the field arriving on a model that *is* aliased.
1412    affiliation: AffiliationInfo | None = None

One row on the user-ranked global scoreboard.

Aggregates a single user's solves across every team they've ever been on (each SolveState carries both user_id and team_id, so per-user totals are a straight filter over the global solve table). Excludes users with zero solves to keep the public list compact.

rank: int = 0
user_id: str = PydanticUndefined
display_name: str = PydanticUndefined
avatar_url: str = ''
country: str = ''
flags_solved: int = 0
solved: int = 0
last_solve_at: datetime.datetime | None = None
affiliation: AffiliationInfo | None = None
class UserSolveTrend(ctfy.core.models.CtfyModel):
491class UserSolveTrend(CtfyModel):
492    """The calling user's daily solve count for the last ``days`` UTC days,
493    aggregated across every team they have ever been on (matched by
494    ``user_id`` on each :class:`SolveState`).
495
496    Powers the Dashboard's "personal solve trend" chart, which is
497    user-scoped — independent of which competition the user is currently
498    registered for. Multi-flag challenges count once per flag day, the
499    same way :class:`ProfileStats.solve_trend` does it.
500    """
501
502    solve_trend: list[TrendPoint] = Field(default_factory=list)

The calling user's daily solve count for the last days UTC days, aggregated across every team they have ever been on (matched by user_id on each SolveState).

Powers the Dashboard's "personal solve trend" chart, which is user-scoped — independent of which competition the user is currently registered for. Multi-flag challenges count once per flag day, the same way ProfileStats.solve_trend does it.

solve_trend: list[TrendPoint] = PydanticUndefined
class VendorStandardSet(ctfy.core.models.CtfyModel):
204class VendorStandardSet(CtfyModel):
205    """A vendor-facing summary of one operator eval standard set.
206
207    A standard set is a competition marked ``eval_standard`` — the fixed
208    challenge subset a vendor self-service campaign runs a model over. This
209    projection is what a ``pv_`` token can see (id / title / size / corpus
210    pin), enough to populate the campaign-launch picker without exposing the
211    full competition surface or requiring a platform user token.
212    """
213
214    id: str = ""
215    title: str = ""
216    description: str = ""  # Markdown
217    challenge_count: int = 0
218    #: corpus snapshot the set was certified against (reproducibility pin).
219    corpus_sha: str = ""

A vendor-facing summary of one operator eval standard set.

A standard set is a competition marked eval_standard — the fixed challenge subset a vendor self-service campaign runs a model over. This projection is what a pv_ token can see (id / title / size / corpus pin), enough to populate the campaign-launch picker without exposing the full competition surface or requiring a platform user token.

id: str = ''
title: str = ''
description: str = ''
challenge_count: int = 0
corpus_sha: str = ''
class VendorStanding(ctfy.core.models.CtfyModel):
115class VendorStanding(CtfyModel):
116    """One vendor's aggregate scorecard across all its evaluated models.
117
118    The vendor's models are pooled: a challenge counts as *solved* if **any**
119    of the vendor's models solved it (the vendor's collective capability), so
120    a vendor fielding several models isn't double-counted per challenge.
121    """
122
123    rank: int = 0
124    vendor: str = ""
125    #: the vendor's curated display name, falling back to the slug. Never
126    #: blank here — an unattributed model contributes to no vendor at all,
127    #: so every row in this table has a slug to fall back to.
128    display_name: str = ""
129    models: int = 0
130    challenges_attempted: int = 0
131    challenges_solved: int = 0
132    solve_rate: float = 0.0
133    questions_solved: int = 0
134    total_tokens: int = 0
135    #: estimated USD cost summed across the vendor's models (0 when unpriced).
136    cost_usd: float = 0.0

One vendor's aggregate scorecard across all its evaluated models.

The vendor's models are pooled: a challenge counts as solved if any of the vendor's models solved it (the vendor's collective capability), so a vendor fielding several models isn't double-counted per challenge.

rank: int = 0
vendor: str = ''
display_name: str = ''
models: int = 0
challenges_attempted: int = 0
challenges_solved: int = 0
solve_rate: float = 0.0
questions_solved: int = 0
total_tokens: int = 0
cost_usd: float = 0.0
class VerifyAddedEmailRequest(ctfy.server.models.AddEmailRequest):
225class VerifyAddedEmailRequest(AddEmailRequest):
226    """Body for ``POST /me/emails/verify``.
227
228    Carries the address as well as the code because a caller may have
229    more than one add in flight, and because the code table is keyed by
230    ``(address, purpose)`` — asking which address this code is for is
231    the lookup, not a convenience.
232    """
233
234    code: str = Field(min_length=1, max_length=16)
235    #: Go ahead even though the address is the other account's last
236    #: credential.
237    #:
238    #: ⚠️ **Required for that one case, and it defaults to refusing.**
239    #: A caller that never heard of handovers — an older client, the
240    #: CLI, an agent — must not be able to make an account unreachable
241    #: by omission: the empty case has to assert *no*, or the default is
242    #: a new claim about a row nobody can sign into afterwards. Every
243    #: other handover needs nothing, because the losing account keeps a
244    #: way in and is told what happened.
245    acknowledge_orphan: bool = False

Body for POST /me/emails/verify.

Carries the address as well as the code because a caller may have more than one add in flight, and because the code table is keyed by (address, purpose) — asking which address this code is for is the lookup, not a convenience.

code: str = PydanticUndefined
acknowledge_orphan: bool = False
class VerifyAnswerRequest(ctfy.core.models.CtfyModel):
576class VerifyAnswerRequest(CtfyModel):
577    """Oracle-mode answer check — same grader as /submissions but writes
578    no submission record. Carries the same shape as :class:`SubmissionCreate`
579    minus the audit trail.
580    """
581
582    # Which question on the challenge the agent is checking. Defaults
583    # to ``"flag"`` for single-question challenges.
584    question_id: str = "flag"
585    # The answer to verify (str for dynamic / static / single_select;
586    # list[str] for multi_select).
587    answer: str | list[str]

Oracle-mode answer check — same grader as /submissions but writes no submission record. Carries the same shape as SubmissionCreate minus the audit trail.

question_id: str = 'flag'
answer: str | list[str] = PydanticUndefined
class VerifyAnswerResponse(ctfy.core.models.CtfyModel):
590class VerifyAnswerResponse(CtfyModel):
591    correct: bool
592    # Echoes the request's ``question_id`` on a correct match; ``None``
593    # on a wrong claim. Useful for batched verification calls where the
594    # client wants to confirm which check the response refers to.
595    question_id: str | None = None

Project-wide Pydantic base.

The one config tweak: json_schema_serialization_defaults_required=True.

By default Pydantic treats "has a default" as "optional in the JSON Schema", which makes every frontend type generated from our OpenAPI doc become field?: T. But at serialization time these fields are always present (the default fills in), so the client never legitimately sees undefined. Marking them required in the schema gives the frontend accurate non-optional types without forcing the backend to drop sensible defaults.

correct: bool = PydanticUndefined
question_id: str | None = None
class VerifyEmailRequest(ctfy.core.models.CtfyModel):
126class VerifyEmailRequest(CtfyModel):
127    """Body for ``POST /auth/email-verification/verify``.
128
129    ⚠️ **No address field, deliberately.** The code always goes to — and
130    is checked against — the address already on the calling account. An
131    address in the body would make this endpoint a way to mail six
132    digits to anywhere, signed by the platform, which is the spam cannon
133    the sign-in flow's rate limits exist to prevent; it would also let a
134    caller verify a *different* address onto their own account.
135    """
136
137    code: str = Field(min_length=1, max_length=16)

Body for POST /auth/email-verification/verify.

⚠️ No address field, deliberately. The code always goes to — and is checked against — the address already on the calling account. An address in the body would make this endpoint a way to mail six digits to anywhere, signed by the platform, which is the spam cannon the sign-in flow's rate limits exist to prevent; it would also let a caller verify a different address onto their own account.

code: str = PydanticUndefined
class VirtualGhostBoard(ctfy.core.models.CtfyModel):
1578class VirtualGhostBoard(CtfyModel):
1579    """The source contest's standings at the instant this replay has
1580    reached, with the replayer merged in and ranked among them.
1581
1582    ⚠️ Nothing here is stored. The board is
1583    `compute_scoreboard_filtered` with an `until_iso`, which the
1584    platform has always supported — the replay's own progress and the
1585    source's progression are two reads of facts that already exist.
1586    """
1587
1588    source_competition_id: str
1589    #: How far into the replay the reader is. The client renders a clock
1590    #: from this rather than recomputing it from `starts_at`, so the two
1591    #: cannot disagree about which minute the ghosts are showing.
1592    elapsed_seconds: int = 0
1593    #: The instant in the *source* being shown. Bounded by its end.
1594    cutoff: datetime | None = None
1595    items: list[VirtualGhostRow] = Field(default_factory=list)

The source contest's standings at the instant this replay has reached, with the replayer merged in and ranked among them.

⚠️ Nothing here is stored. The board is compute_scoreboard_filtered with an until_iso, which the platform has always supported — the replay's own progress and the source's progression are two reads of facts that already exist.

source_competition_id: str = PydanticUndefined
elapsed_seconds: int = 0
cutoff: datetime.datetime | None = None
items: list[VirtualGhostRow] = PydanticUndefined
class VirtualGhostRow(ctfy.server.models.ScoreboardEntry):
1567class VirtualGhostRow(ScoreboardEntry):
1568    """A standings row on a replay's ghost board.
1569
1570    `is_me` is the one thing a client cannot derive: the replayer's row
1571    comes from a *different competition* than the ghosts', so there is no
1572    team id it could compare against.
1573    """
1574
1575    is_me: bool = False

A standings row on a replay's ghost board.

is_me is the one thing a client cannot derive: the replayer's row comes from a different competition than the ghosts', so there is no team id it could compare against.

is_me: bool = False