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    UserAchievements,
  16)
  17from ctfy.server.models.activity import (
  18    Activity,
  19    ActivityTimeseries,
  20    ActivityTimeseriesBucket,
  21)
  22from ctfy.server.models.admin import (
  23    AccountKeyRow,
  24    AdminArtifactStorageFlag,
  25    AdminChallengeLastError,
  26    AdminChallengeLatencyBucket,
  27    AdminChallengeSolveStatsRow,
  28    AdminChallengeStatsRow,
  29    AdminChallengeTimeseries,
  30    AdminChallengeTimeseriesBucket,
  31    AdminHealthFlags,
  32    AdminInstanceTimingRow,
  33    AdminInstanceTimings,
  34    AdminOverview,
  35    AdminOverviewCounts,
  36    AdminRecentError,
  37    AdminSilentChallenge,
  38    AdminSolveCell,
  39    AdminSolveMatrix,
  40    AdminSolveMatrixChallenge,
  41    AdminSolveMatrixTeam,
  42    AdminStuckInstance,
  43    AdminTaskInfo,
  44    AdminTaskListResponse,
  45    AdminTaskLogLine,
  46    AdminTaskLogsResponse,
  47    AdminTaskSubmitRequest,
  48    AdminTimeseries,
  49    AdminTimeseriesBucket,
  50    AdminTimeseriesMetric,
  51    AdminTrafficInstanceRow,
  52    AdminTrafficSummary,
  53    AdminTrafficTeamRow,
  54    AdminUnhealthyNode,
  55    DeceptionStats,
  56    DeploymentConfig,
  57    LlmBudgetPage,
  58    LlmBudgetResetInfo,
  59    LlmBudgetResetRequest,
  60    LlmBudgetRow,
  61    LlmProviderCreate,
  62    LlmProviderInfo,
  63    LlmProviderPage,
  64    LlmProviderProbe,
  65    PlatformSettingInfo,
  66    PlatformSettingPatch,
  67    QuestionAttemptResetInfo,
  68    QuestionAttemptResetRequest,
  69    ScheduledJobInfo,
  70    ScheduledJobPatch,
  71    ShellRecordingInfo,
  72)
  73from ctfy.server.models.admin_invites import (
  74    AdminInviteInfo,
  75    AdminInviteStatus,
  76    CreateAdminInviteRequest,
  77)
  78from ctfy.server.models.announcement import (
  79    AnnouncementCreate,
  80    AnnouncementInfo,
  81    AnnouncementUpdate,
  82)
  83from ctfy.server.models.auth import (
  84    AddEmailRequest,
  85    AdminTokenRevokeResponse,
  86    AuthTokenResponse,
  87    CreateFineGrainedTokenRequest,
  88    CreateFineGrainedTokenResponse,
  89    DeviceApproveRequest,
  90    DeviceCodeResponse,
  91    DeviceInfoResponse,
  92    DeviceTokenRequest,
  93    DeviceTokenResponse,
  94    EmailCodeAuthInfo,
  95    EmailVerificationSent,
  96    EmailVerificationStatus,
  97    LinkedIdentity,
  98    LinkStartResponse,
  99    LoginRequest,
 100    OAuthProviderInfo,
 101    PasswordAuthInfo,
 102    ProvidersResponse,
 103    RegisterRequest,
 104    ScopeCatalogEntry,
 105    SetPasswordRequest,
 106    TokenInfo,
 107    TokenLifetimeRequest,
 108    TokenScopesResponse,
 109    TokenScopeUpdate,
 110    TokenTemplatesResponse,
 111    TokenTraceLink,
 112    TokenTraceRequest,
 113    TokenTraceResponse,
 114    UserEmailInfo,
 115    VerifyAddedEmailRequest,
 116    VerifyEmailRequest,
 117)
 118from ctfy.server.models.authoring import (
 119    AcceptTeamAgreementRequest,
 120    AdminAuthoredChallengeDetail,
 121    AdminAuthoredChallengeRow,
 122    AdminAuthoredCurationRow,
 123    AdminAuthoredRevisionRow,
 124    AdminAuthoredVersionRow,
 125    AdminMaliceBrief,
 126    AdminMaliceInfo,
 127    AdminMaliceRuleHit,
 128    AdminMaliceStanding,
 129    AdminModelReview,
 130    AdminModelReviewBrief,
 131    AdminModelReviewInfo,
 132    AdminProbeInfo,
 133    AuditFinding,
 134    AuthoredActionEffectInfo,
 135    AuthoredAudience,
 136    AuthoredAudienceCompetition,
 137    AuthoredAuditReport,
 138    AuthoredAutoSmoke,
 139    AuthoredBuildCapability,
 140    AuthoredChallengeFileBody,
 141    AuthoredChallengeFileEntry,
 142    AuthoredChallengeFileRequest,
 143    AuthoredChallengeInfo,
 144    AuthoredChallengeListRow,
 145    AuthoredChallengeReception,
 146    AuthoredChallengeReviewRequest,
 147    AuthoredChallengeRow,
 148    AuthoredChangedFile,
 149    AuthoredCheckProblem,
 150    AuthoredCheckReport,
 151    AuthoredCompetitionRow,
 152    AuthoredCurationElsewhere,
 153    AuthoredCurationState,
 154    AuthoredDeclarations,
 155    AuthoredFlow,
 156    AuthoredFlowGraph,
 157    AuthoredFlowLogEntry,
 158    AuthoredFlowStateDef,
 159    AuthoredFlowTransitionDef,
 160    AuthoredFlowTransitionInfo,
 161    AuthoredGraduationInfo,
 162    AuthoredHistory,
 163    AuthoredHistoryEntry,
 164    AuthoredLifecycleRow,
 165    AuthoredMaliceOverrideRequest,
 166    AuthoredModelReviewOverrideRequest,
 167    AuthoredOperatorEdit,
 168    AuthoredOperatorRevisionInfo,
 169    AuthoredOtherUpload,
 170    AuthoredOtherUploads,
 171    AuthoredPointsAtStake,
 172    AuthoredProbeCell,
 173    AuthoredQualityFlag,
 174    AuthoredQueueCounts,
 175    AuthoredReceptionRow,
 176    AuthoredReceptionState,
 177    AuthoredRepublishRequest,
 178    AuthoredRevisionDiff,
 179    AuthoredRevisionFileChange,
 180    AuthoredRevisionInfo,
 181    AuthoredRevisionRow,
 182    AuthoredRunningCompetition,
 183    AuthoredSameTitle,
 184    AuthoredSmokeLine,
 185    AuthoredSmokeLive,
 186    AuthoredSmokeLiveStep,
 187    AuthoredSmokeLog,
 188    AuthoredSmokeLogSummary,
 189    AuthoredSmokeStepLines,
 190    AuthoredSmokeStepLog,
 191    AuthoredStandardCheck,
 192    AuthoredStrayRow,
 193    AuthoredStripResult,
 194    AuthoredUnpublishRequest,
 195    AuthoredUploadAccepted,
 196    AuthoredVersionDiff,
 197    AuthoredVersionFileChange,
 198    AuthoredVersionMessage,
 199    AuthoredVersionMessageRequest,
 200    AuthoredVersionRow,
 201    AuthoredVersionStorageInfo,
 202    AuthoredVersionSwitchInfo,
 203    AuthoredVersionSwitchPlan,
 204    AuthoredVersionSwitchRequest,
 205    AuthoringDivision,
 206    AuthoringPauseInfo,
 207    AuthoringQuotaInfo,
 208    AuthoringRateInfo,
 209    AuthoringReceptionInfo,
 210    AuthoringTemplateFile,
 211    AuthoringTemplateManifest,
 212    AuthoringWindowQuotaInfo,
 213    AuthorModelReview,
 214    AuthorModelReviewInfo,
 215    AuthorProbeInfo,
 216    AuthorReputation,
 217    AuthorVersionRow,
 218    BulkCurationRequest,
 219    BulkCurationResponse,
 220    BulkCurationResult,
 221    BulkCurationTarget,
 222    BulkCurationUpload,
 223    ContributorRow,
 224    CreditedChallengeRow,
 225    PrecheckInstanceInfo,
 226    PrecheckQuestion,
 227    PrecheckRerunFinding,
 228    PrecheckRerunInfo,
 229    PrecheckRerunStart,
 230    PrecheckRerunSummary,
 231    PrecheckRerunView,
 232    PrecheckRound,
 233    PrecheckRunRow,
 234    PrecheckRunsInfo,
 235    PrecheckSourceInfo,
 236    PrecheckSubmission,
 237    PrecheckSubmissionResult,
 238    PrecheckSubmissionRow,
 239    ProbeBrief,
 240    ProbeQuestionTally,
 241    ProbeRun,
 242    ProbeSmoke,
 243    ProbeVersion,
 244    ReactionCount,
 245    SmokeQueueInfo,
 246    SmokeRunTiming,
 247    StandardCheckState,
 248    TeamAuthoringAgreementInfo,
 249    TeamAuthoringAgreementStatus,
 250)
 251from ctfy.server.models.awd import (
 252    MAX_FLAGS_PER_SUBMISSION,
 253    AwdArenaGate,
 254    AwdArenaGateLink,
 255    AwdArenaLink,
 256    AwdArenaPeerRow,
 257    AwdArenaPeersResponse,
 258    AwdAttackAnchorResponse,
 259    AwdAttackAnchorRow,
 260    AwdAttackMatrixResponse,
 261    AwdAttackMatrixRow,
 262    AwdBoardEntry,
 263    AwdBoardResponse,
 264    AwdCaptureCorroborationRow,
 265    AwdCorroborationResponse,
 266    AwdDefenceAnchorResponse,
 267    AwdDefenceAnchorRow,
 268    AwdDefenceTimelineCell,
 269    AwdDefenceTimelineResponse,
 270    AwdDefenceTimelineRow,
 271    AwdEventRow,
 272    AwdEventsResponse,
 273    AwdFlagResult,
 274    AwdFlagSubmitRequest,
 275    AwdFlagSubmitResponse,
 276    AwdForensicAnalysis,
 277    AwdForensicsResponse,
 278    AwdForensicsRow,
 279    AwdMatchConfig,
 280    AwdMatchInfo,
 281    AwdMatchStatus,
 282    AwdProvisionPlanInfo,
 283    AwdRoundHistoryResponse,
 284    AwdRoundPoint,
 285    AwdRoundSeries,
 286    AwdSlaHistoryResponse,
 287    AwdSlaProbeRow,
 288    AwdTarget,
 289    AwdTargetsResponse,
 290    AwdVpnPeer,
 291    AwdVpnRequest,
 292    AwdVpnStatus,
 293)
 294from ctfy.server.models.challenge import (
 295    AdminChallengeQuestions,
 296    AdminPatchSubmissionDetail,
 297    AdminPatchSubmissionInfo,
 298    AdminPatchSubmissionPage,
 299    AgentEntrantInfo,
 300    AttachmentList,
 301    BlueprintHostInfo,
 302    BlueprintMilestoneInfo,
 303    BuildPushScopeRequest,
 304    ChallengeBlueprint,
 305    ChallengeBuildKickoffNodeResult,
 306    ChallengeBuildKickoffResponse,
 307    ChallengeBuildNodeState,
 308    ChallengeBuildStateResponse,
 309    ChallengeBuildStateRow,
 310    ChallengeFacetCount,
 311    ChallengeFacets,
 312    ChallengeInfo,
 313    ChallengePullKickoffNodeResult,
 314    ChallengePullKickoffResponse,
 315    ChallengePullNodeState,
 316    ChallengePullStateResponse,
 317    ChallengePullStateRow,
 318    ChallengeRescanNodeResult,
 319    ChallengeRescanResult,
 320    ChallengeSolveAttempt,
 321    ChallengeSolveAttemptsResponse,
 322    ChallengeTeamSolveSummary,
 323    ChallengeValuePoint,
 324    CompetitionChallengeBreakdown,
 325    CompetitionChallengeRow,
 326    CompetitionScoreDistribution,
 327    CompetitionScoreHistory,
 328    DefenceInfo,
 329    DefenceLiveFile,
 330    DefenceMatrixResponse,
 331    DefenceMatrixRow,
 332    DefenceSource,
 333    MilestoneProgress,
 334    MySolveSummary,
 335    PatchStepInfo,
 336    PatchSubmissionCreate,
 337    PatchSubmissionInfo,
 338    QaChallengeView,
 339    QaSubmissionCreate,
 340    QuestionPublicInfo,
 341    RangeProgress,
 342    RangeProgressRow,
 343    RangeReplay,
 344    RangeReplayFrame,
 345    RangeReplayPosition,
 346    ScoreboardEntry,
 347    ScoreBucket,
 348    ScoreHistoryPoint,
 349    ScoreHistorySeries,
 350    SubmissionCreate,
 351    SubmissionResponse,
 352    TeamPracticeSummary,
 353)
 354from ctfy.server.models.challenge_stats import (
 355    ChallengeDifficultyReading,
 356    ChallengeEnvironment,
 357    ChallengeFunnel,
 358    ChallengePerformance,
 359    ChallengePerformanceRow,
 360    ChallengeReceptionStats,
 361    ChallengeTeamDetail,
 362    ChallengeTeamRow,
 363    SolvePoint,
 364    WrongAnswer,
 365)
 366from ctfy.server.models.changelog import (
 367    AdminChangelogEntryInfo,
 368    ChangelogAnnounceRequest,
 369    ChangelogAnnounceResponse,
 370    ChangelogEntryCreate,
 371    ChangelogEntryInfo,
 372    ChangelogEntryUpdate,
 373    ChangelogImportEntry,
 374    ChangelogImportRequest,
 375    ChangelogImportResponse,
 376    ChangelogLatestResponse,
 377    ChangelogListResponse,
 378    ChangelogSeenResponse,
 379)
 380from ctfy.server.models.competition import (
 381    AdminCompetitionAttachmentsResponse,
 382    AdminRepriceInfo,
 383    AdminScoringChangeInfo,
 384    AdminScoringChangesResponse,
 385    AuthoringChecklistInfo,
 386    AuthoringChecklistRow,
 387    AuthoringReviewerInfo,
 388    AuthoringScaleInfo,
 389    AuthoringSmokeLedgerInfo,
 390    AuthoringSmokeRunRow,
 391    AuthoringWindowInfo,
 392    AwdClockBrief,
 393    BoardCriterion,
 394    BulkInviteRequest,
 395    BulkInviteResponse,
 396    BulkInviteResultRow,
 397    ChallengeInternetRow,
 398    ChallengeScheduleRow,
 399    ChallengeScheduleUpdate,
 400    ChallengeSourceCompetition,
 401    ChallengeTcpAccessRow,
 402    CompetitionAdminGrantRequest,
 403    CompetitionAdminInfo,
 404    CompetitionApplicationInfo,
 405    CompetitionApplicationRequest,
 406    CompetitionApplicationReviewRequest,
 407    CompetitionApplicationRow,
 408    CompetitionAttachmentInfo,
 409    CompetitionAttachmentsResponse,
 410    CompetitionAttachmentUpdate,
 411    CompetitionBoardCreate,
 412    CompetitionBoardInfo,
 413    CompetitionBoardsResponse,
 414    CompetitionBoardUpdate,
 415    CompetitionChallengeChangeInfo,
 416    CompetitionChallengeOrder,
 417    CompetitionChallengeSchedule,
 418    CompetitionChallengeSource,
 419    CompetitionCloneRequest,
 420    CompetitionCreate,
 421    CompetitionDetail,
 422    CompetitionDifferenceInfo,
 423    CompetitionDriftInfo,
 424    CompetitionFormatPreset,
 425    CompetitionInfo,
 426    CompetitionInternetApplied,
 427    CompetitionInternetApplyRequest,
 428    CompetitionInternetInfo,
 429    CompetitionInviteInfo,
 430    CompetitionMembershipInfo,
 431    CompetitionPinnedChallenges,
 432    CompetitionPoolInfo,
 433    CompetitionPoolNode,
 434    CompetitionPreviewChallenges,
 435    CompetitionPrewarm,
 436    CompetitionRegistrationInfo,
 437    CompetitionSessionEnd,
 438    CompetitionSessionExtend,
 439    CompetitionSessionInfo,
 440    CompetitionTcpAccessInfo,
 441    CompetitionTopicCreate,
 442    CompetitionTopicInfo,
 443    CompetitionTopicsResponse,
 444    CompetitionTopicUpdate,
 445    CompetitionUpdate,
 446    GrantAuthoringReviewerRequest,
 447    GrantCompetitionAdminRequest,
 448    PrewarmNodeResult,
 449    PrewarmRow,
 450    PrewarmRunResult,
 451    ProbeModelChoice,
 452    RepriceInfo,
 453    RepriceRequest,
 454    ScoringChangeBoardRow,
 455    ScoringChangeInfo,
 456    ScoringChangesResponse,
 457    ScoringManifestEntryInfo,
 458    ScoringManifestInfo,
 459    ScoringManifestPrice,
 460    ScoringManifestRow,
 461    ScoringPreview,
 462    ScoringPreviewBlood,
 463    ScoringPreviewChallenge,
 464    ScoringPreviewRequest,
 465    ScoringPreviewRow,
 466    ScoringPreviewSeries,
 467    VirtualGhostBoard,
 468    VirtualGhostRow,
 469)
 470from ctfy.server.models.eligibility import (
 471    CompetitionEligibility,
 472    EligibilityImpact,
 473    EligibleInstitution,
 474)
 475from ctfy.server.models.env_sharing import (
 476    EnvSharingChallengeRow,
 477    EnvSharingGrouping,
 478    EnvSharingUpdate,
 479    EnvSharingView,
 480    GroupingDraw,
 481    GroupingTable,
 482    GroupRow,
 483    GroupTeamRow,
 484    SharedEnvKeyRequest,
 485    SharedEnvReportResult,
 486    SharedEnvRow,
 487    SharedEnvTable,
 488)
 489from ctfy.server.models.eval import (
 490    RUN_ARTIFACT_BLOCKED,
 491    RUN_TRAFFIC_BLOCKED,
 492    RUN_TRANSCRIPT_BLOCKED,
 493    RUN_WALL_BLOCKED,
 494    RUN_WRITEUP_BLOCKED,
 495    TRANSCRIPT_REFUSALS,
 496    WALL_REFUSALS,
 497    AdminHarnessRow,
 498    AgentActionRow,
 499    AgentActionsPage,
 500    AgentLogPage,
 501    AgentPulseBeat,
 502    AgentPulseLane,
 503    AgentPulsePage,
 504    BoardHarnessOption,
 505    BoardRevision,
 506    BoardTotals,
 507    BoardVendorOption,
 508    EvalAllowanceGrant,
 509    EvalAllowanceRow,
 510    EvalArtifactBucket,
 511    EvalArtifactReport,
 512    EvalBatchCancelResult,
 513    EvalBatchLimits,
 514    EvalBatchModel,
 515    EvalBatchRequest,
 516    EvalBatchState,
 517    EvalBoardScope,
 518    EvalCampaignAtomicSummary,
 519    EvalCancelRequest,
 520    EvalEntryRequest,
 521    EvalLeaderboard,
 522    EvalMatchRoom,
 523    EvalQueueChange,
 524    EvalRangeFailureReport,
 525    EvalRunCoverage,
 526    EvalRunEnvironment,
 527    EvalRunEvent,
 528    EvalRunEventList,
 529    EvalRunFailure,
 530    EvalRunLlmActivity,
 531    EvalRunPanel,
 532    EvalRunQuestion,
 533    EvalRunRangeFailure,
 534    EvalRunSubmission,
 535    EvalTrendPoint,
 536    HarnessAdmission,
 537    HarnessCreate,
 538    HarnessModelCell,
 539    HarnessRegistryCredentialPut,
 540    HarnessRegistryCredentialView,
 541    HarnessStanding,
 542    HarnessTags,
 543    HarnessUpdate,
 544    LeaderboardCell,
 545    LlmAccountChoice,
 546    LlmAccountSurface,
 547    LlmBalanceLine,
 548    LlmBalanceRefresh,
 549    LlmCallBody,
 550    LlmCallPage,
 551    LlmCallRow,
 552    LlmConnectionBalance,
 553    LlmConnectionRef,
 554    LlmConnectionUsage,
 555    LlmLivePage,
 556    LlmModelOption,
 557    LlmMoney,
 558    LlmProviderChoice,
 559    LlmRunUsage,
 560    LlmSpendCap,
 561    LlmSpendCapUpdate,
 562    LlmSurfaceStatus,
 563    LlmUsagePage,
 564    LlmUsageTotals,
 565    ModelStanding,
 566    MyEvalRunPage,
 567    MyEvalRunRow,
 568    MyHarness,
 569    MyLlmConnection,
 570    MyLlmConnectionCreate,
 571    MyLlmKey,
 572    MyLlmKeyCreate,
 573    MyLlmKeyProbe,
 574    PairStanding,
 575    PublicTranscriptBlock,
 576    PublicTranscriptResponse,
 577    PublicTranscriptTurn,
 578    QuestionCoverageRow,
 579    RegistryPushTarget,
 580    RegistryQuotaInfo,
 581    RegistryUsageReport,
 582    RegistryUsageRow,
 583    RunTranscriptRequest,
 584    RunWallRequest,
 585    RunWriteup,
 586    VendorStandardSet,
 587    VendorStanding,
 588)
 589from ctfy.server.models.exclusion import (
 590    CompetitionExclusion,
 591    CompetitionGroupRef,
 592    CompetitionGroupSet,
 593    ExclusionConflictAccount,
 594    ExclusionConflictPerson,
 595    ExclusionConflictReport,
 596    ExclusionConflictSeat,
 597    ExclusionGroupConflicts,
 598    ExclusionGroupCreate,
 599    ExclusionGroupInfo,
 600    ExclusionGroupPage,
 601    ExclusionGroupUpdate,
 602    ExclusionNoticeChannel,
 603    ExclusionNoticeItem,
 604    ExclusionNoticeOutcome,
 605    ExclusionNoticeRequest,
 606    ExclusionNoticeResult,
 607    ExclusionPeer,
 608)
 609from ctfy.server.models.favourites import MyFavourites
 610from ctfy.server.models.feedback import (
 611    AdminFeedbackRow,
 612    FeedbackStats,
 613    MyReactionsResponse,
 614)
 615from ctfy.server.models.gauntlet import (
 616    GauntletBuildCost,
 617    GauntletBuildStep,
 618    GauntletLibraryResponse,
 619    GauntletModuleInfo,
 620    GauntletPlanRequest,
 621    GauntletPlanResponse,
 622    GauntletPreset,
 623    GauntletPreviewRequest,
 624    GauntletPreviewResponse,
 625    GauntletRangeCreate,
 626    GauntletRangeCreated,
 627    GauntletRangeInfo,
 628    GauntletRangeListResponse,
 629    GauntletShortfall,
 630)
 631from ctfy.server.models.human_check import HumanCheckRequirement
 632from ctfy.server.models.instance import (
 633    AdminInstanceInfo,
 634    AnswerTraceHit,
 635    AnswerTraceRequest,
 636    AnswerTraceResponse,
 637    ArchiveStoreReport,
 638    CheckAnswerRequest,
 639    CheckAnswerResponse,
 640    InstanceAnswersResponse,
 641    InstanceInfo,
 642    InstanceInternetRequest,
 643    InstanceInternetResult,
 644    InstanceQuestionInfo,
 645    InstanceRecordArtifacts,
 646    InstanceRecordDetail,
 647    InstanceRecordInfo,
 648    InstanceRecordInfoDetail,
 649    InstanceStatusResponse,
 650    InstanceStorageReport,
 651    InstanceTrafficPage,
 652    InstanceTrafficWitnesses,
 653    NodeCaptureStoreRow,
 654    PlayerShellCreate,
 655    PlayerShellTicket,
 656    RenewResponse,
 657    RevealedAnswer,
 658    SshCredential,
 659    SshExchangeRequest,
 660    StartRequest,
 661    StartResponse,
 662    StartWait,
 663    StopResponse,
 664    TrafficWitness,
 665    VerifyAnswerRequest,
 666    VerifyAnswerResponse,
 667)
 668from ctfy.server.models.institution import (
 669    AffiliationInfo,
 670    AffiliationSelection,
 671    InstitutionCreate,
 672    InstitutionInfo,
 673    InstitutionPage,
 674    InstitutionSuggestion,
 675    InstitutionSuggestionPage,
 676)
 677from ctfy.server.models.ip_location import IpLocationDatabase, IpLocationStatus
 678from ctfy.server.models.landing import (
 679    FeaturedStatus,
 680    FeaturedTrack,
 681    LandingDomain,
 682    LandingEventWindow,
 683    LandingFeatured,
 684    LandingPrimarySuite,
 685    LandingResponse,
 686    PublicEvalDomainRate,
 687    PublicEvalHour,
 688    PublicEvalRun,
 689    PublicEvalStats,
 690    PublicFirstSolve,
 691    PublicRoundedCount,
 692    PublicScale,
 693    PublicStatsResponse,
 694)
 695from ctfy.server.models.machines import (
 696    CompetitionMachineRow,
 697    CompetitionMachines,
 698    InboxMachineNotice,
 699    MachineAssignOutcome,
 700    MachineAssignRequest,
 701    MachineAssignResult,
 702    MachineImportError,
 703    MachineImportRequest,
 704    MachineImportResult,
 705    MachineNotifyRequest,
 706    MachineNotifyResult,
 707    MachineTeamOption,
 708    TeamMachineLogin,
 709    TeamMachineStatus,
 710    UnresolvedPin,
 711)
 712from ctfy.server.models.messages import (
 713    AudiencePreview,
 714    AudienceRequest,
 715    ComposeRequest,
 716    ComposeResult,
 717    ContactOrganisersRequest,
 718    InboxFacet,
 719    InboxItem,
 720    InboxOverview,
 721    InboxRecord,
 722    MessageSecrets,
 723    MessageTeamOption,
 724    MessageThreadDetail,
 725    MessageThreadRow,
 726    PersonalisedColumn,
 727    PersonalisedPreview,
 728    PersonalisedPreviewItem,
 729    PersonalisedRow,
 730    PersonalisedSendRequest,
 731    PlatformComposeRequest,
 732    ReadAllResult,
 733    ReplyRequest,
 734    SiteMessageView,
 735    ThreadEvent,
 736    ThreadMember,
 737    ThreadStatusRequest,
 738)
 739from ctfy.server.models.meta import (
 740    ErrorResponse,
 741    HealthResponse,
 742    MetaAgents,
 743    MetaAlert,
 744    MetaAlerts,
 745    MetaAuthoredArchive,
 746    MetaChallenges,
 747    MetaContribute,
 748    MetaEvents,
 749    MetaFreeze,
 750    MetaInstanceTtl,
 751    MetaLlm,
 752    MetaMine,
 753    MetaNode,
 754    MetaNodePool,
 755    MetaOps,
 756    MetaPlatform,
 757    MetaResponse,
 758    PublicStatusComponent,
 759    PublicStatusResponse,
 760)
 761from ctfy.server.models.node import (
 762    ClusterInfo,
 763    CreateInviteRequest,
 764    CreateInviteResponse,
 765    CreateJoinCommandRequest,
 766    CreateJoinCommandResponse,
 767    FleetReachAck,
 768    FleetReachAckCleared,
 769    FleetReachAckResult,
 770    FleetReachEntry,
 771    FleetReachGap,
 772    FleetReachManifest,
 773    FleetReachToken,
 774    HarnessExploit,
 775    ImageConsistencyRow,
 776    NodeAddress,
 777    NodeAddressEcho,
 778    NodeApproveRequest,
 779    NodeAuthoredArchiveLimits,
 780    NodeAuthoredPush,
 781    NodeAuthoredResult,
 782    NodeAuthoredSmokePush,
 783    NodeAuthoredSmokeResult,
 784    NodeAuthoredSyncLine,
 785    NodeCapabilities,
 786    NodeCaptureStoreReport,
 787    NodeControlCheck,
 788    NodeFeatureRow,
 789    NodeFeatures,
 790    NodeHeartbeat,
 791    NodeHeartbeatResponse,
 792    NodeInfo,
 793    NodeInviteInfo,
 794    NodePatch,
 795    NodeRangePush,
 796    NodeRangeResult,
 797    NodeRangeSyncLine,
 798    NodeRangeVerifyPush,
 799    NodeRangeVerifyResult,
 800    NodeReachProbe,
 801    NodeReachReading,
 802    NodeRegister,
 803    NodeRegisterResponse,
 804    NodeRegistryTicket,
 805    NodeRegistryTicketRequest,
 806    NodeSmokeEntrance,
 807    NodeSmokeExploitOutcome,
 808    NodeUdpCheck,
 809    PlaneEnrollRequest,
 810    PlaneEnrollResponse,
 811)
 812from ctfy.server.models.notification import (
 813    EmailSuppressionCreate,
 814    EmailSuppressionInfo,
 815    EmailSuppressionListResponse,
 816    EmailTemplateInfo,
 817    EmailTemplateListResponse,
 818    NotificationCategoryInfo,
 819    NotificationChannelCell,
 820    NotificationPreferencesResponse,
 821    NotificationPreferenceUpdate,
 822    PushSubscriptionCreate,
 823    PushSubscriptionInfo,
 824    PushSubscriptionKeys,
 825    PushSubscriptionsResponse,
 826    TestEmailRequest,
 827    TestEmailResult,
 828)
 829from ctfy.server.models.pagination import (
 830    BigLimitOffsetPage,
 831    BigLimitOffsetPageOf100,
 832    LimitOffsetPageUpTo200,
 833    LimitOffsetPageUpTo500,
 834)
 835from ctfy.server.models.recap import (
 836    AwardBandInfo,
 837    CompetitionEditionRef,
 838    CompetitionRecapMail,
 839    CompetitionRecapMailUpdate,
 840    CompetitionRecapSettings,
 841    CompetitionRecapSettingsUpdate,
 842    PublicRecap,
 843    PublicRecapReach,
 844    Recap,
 845    RecapDueInfo,
 846    RecapLinkReadRequest,
 847    RecapLinkView,
 848    RecapMailPreview,
 849    RecapRecipientInfo,
 850    RecapSendInfo,
 851)
 852from ctfy.server.models.records import (
 853    CompetitionRecords,
 854    RecordAuthoredChallenge,
 855    RecordChallengeChange,
 856    RecordEvalRun,
 857    RecordSolve,
 858    RecordSubmission,
 859    RecordTeam,
 860)
 861from ctfy.server.models.registration import (
 862    PREVIEW_TEAM_ROWS,
 863    BulkApproveRequest,
 864    BulkApproveResponse,
 865    BulkApproveResult,
 866    BulkApproveTeam,
 867    CapacityWatchResult,
 868    ChangedSinceVerdict,
 869    ConditionCellInfo,
 870    ConditionMemberRow,
 871    ConditionsPreviewItem,
 872    ConditionsPreviewTeam,
 873    ConditionTeamResult,
 874    ConditionUnmetItem,
 875    ExclusionConflictInfo,
 876    ExclusionConflictMember,
 877    LastRejection,
 878    MyRegistrationResponse,
 879    MyReviewTimeline,
 880    MyReviewTimelineEvent,
 881    MyTeamTodosResponse,
 882    PlayerCapacity,
 883    RegistrationCapacity,
 884    RegistrationCarsiEvidence,
 885    RegistrationConditionCounts,
 886    RegistrationConditionsPreview,
 887    RegistrationEmailEvidence,
 888    RegistrationFieldSpec,
 889    RegistrationFormSpec,
 890    RegistrationIdentityEvidence,
 891    RegistrationMatch,
 892    RegistrationMemberIdentityEvidence,
 893    RegistrationMessageTemplate,
 894    RegistrationMessageTemplates,
 895    RegistrationMessageTemplateWrite,
 896    RegistrationReviewNote,
 897    RegistrationReviewNoteRequest,
 898    RegistrationReviewNotes,
 899    RegistrationReviewProgress,
 900    RegistrationRosterRow,
 901    RegistrationSubmitRequest,
 902    RegistrationSummary,
 903    RegistrationTeamDetail,
 904    RegistrationTeamRow,
 905    RegistrationTodoInfo,
 906    RenderedReviewMessage,
 907    ReviewDecisionRequest,
 908    ReviewerVerdictCount,
 909    ReviewFieldDelta,
 910    ReviewPresence,
 911    ReviewPresenceBeat,
 912    ReviewStaleDetail,
 913    ReviewStaleError,
 914    ReviewTimeline,
 915    ReviewTimelineChange,
 916    ReviewTimelineEvent,
 917    ReviewTimelineTodo,
 918    ReviewTodoItemRequest,
 919    ReviewTodoRow,
 920    ReviewVerdictInfo,
 921    ReviewViewer,
 922    SchoolIdentity,
 923    TeamConditionsVerdict,
 924    TeamLogoUploadResponse,
 925    TeamNameChangeRow,
 926    TeamTodoProgressRow,
 927    WaitlistBackfillRequest,
 928    WaitlistBackfillResult,
 929)
 930from ctfy.server.models.registry import (
 931    RegistryGcStatus,
 932    RegistryManifestRow,
 933    RegistryReclaimItem,
 934    RegistryReclaimPlan,
 935    RegistryReclaimRequest,
 936    RegistryRepo,
 937    RegistryRepoList,
 938    RegistryTagList,
 939)
 940from ctfy.server.models.release import (
 941    HintAttachmentUpload,
 942    HintDraftBody,
 943    HintEditorTier,
 944    HintEditorView,
 945    HintScenario,
 946    HintViewResult,
 947    InboxReleaseNotice,
 948    PlayerHintList,
 949    PlayerHintStatus,
 950    PlayerHintTier,
 951    ReleaseChallengeView,
 952    ReleaseDraftBody,
 953    ReleaseFindingInfo,
 954    ReleaseHintMarker,
 955    ReleasePlanView,
 956    ReleaseStatus,
 957    ReleaseSubjectBody,
 958    ReleaseWaveCreate,
 959    ReleaseWaveView,
 960)
 961from ctfy.server.models.report import (
 962    MyFactVerdict,
 963    MyReportGrade,
 964    MyReportGradesResponse,
 965    MyReportRow,
 966    MyReportsResponse,
 967    ReportAttachmentInfo,
 968    ReportAttachmentsResponse,
 969    ReportClaimRow,
 970    ReportClaimsPage,
 971    ReportGradeInfo,
 972    ReportGradesResponse,
 973    ReportGradeSummary,
 974    ReportInfo,
 975    ReportPipeline,
 976    ReportPipelineEvent,
 977    ReportRosterResponse,
 978    ReportRosterRow,
 979    ReportSaveRequest,
 980    ReportSaveResponse,
 981    ReportStanding,
 982    ReportVersionsResponse,
 983)
 984from ctfy.server.models.results import (
 985    AgentEvalBest,
 986    MyResults,
 987    ProfileAgent,
 988    ProfileAgents,
 989    ProfileResults,
 990    SittingResult,
 991)
 992from ctfy.server.models.risk import (
 993    RiskCaseDetail,
 994    RiskCaseList,
 995    RiskCaseRow,
 996    RiskDeviceAccount,
 997    RiskDeviceGraph,
 998    RiskDeviceLink,
 999    RiskEvidenceRow,
1000    RiskSettings,
1001    RiskTeamRef,
1002    RiskTimeline,
1003    RiskTimingPoint,
1004    SightingRow,
1005    SightingsResponse,
1006)
1007from ctfy.server.models.risk_actions import (
1008    AppealReplyRequest,
1009    AppealRequest,
1010    CaseCloseRequest,
1011    RiskActionRow,
1012    RiskAppealList,
1013    RiskAppealRow,
1014    RiskAppealView,
1015    RiskNotice,
1016    WarnRequest,
1017)
1018from ctfy.server.models.search import (
1019    SearchGroup,
1020    SearchGroupName,
1021    SearchHit,
1022    SearchKind,
1023    SearchResponse,
1024    SearchScope,
1025)
1026from ctfy.server.models.series import (
1027    CompetitionSeriesConfig,
1028    CompetitionSeriesInfo,
1029    SeriesOccurrencePreview,
1030    SeriesPreview,
1031)
1032from ctfy.server.models.solve_voiding import (
1033    FirstBloodMove,
1034    RestoreResult,
1035    SolveRef,
1036    TeamSolveRow,
1037    VoidPreview,
1038    VoidPreviewRequest,
1039    VoidResult,
1040    VoidSolvesRequest,
1041)
1042from ctfy.server.models.suite import (
1043    SuiteBestScore,
1044    SuiteCategoryShare,
1045    SuiteDifficulty,
1046    SuiteInfo,
1047    SuiteMatrixCell,
1048    SuiteMatrixRow,
1049    SuiteResults,
1050    SuiteResultsRow,
1051)
1052from ctfy.server.models.tasks import (
1053    MyTaskDetail,
1054    TaskCard,
1055    TaskCardCounts,
1056    TaskCardList,
1057    TaskCardSubject,
1058)
1059from ctfy.server.models.team import (
1060    AdminAddMemberRequest,
1061    AdminTeamRow,
1062    BatchAddMembersRequest,
1063    BatchAddMembersResponse,
1064    BatchMemberResultRow,
1065    CompetitionBanRequest,
1066    CompetitionBanRow,
1067    CompetitionRoster,
1068    CompetitionRosterTeam,
1069    DirectInviteSent,
1070    InboxAnnouncement,
1071    InboxApplicationQueue,
1072    InboxAuthoringQueue,
1073    InboxCaptainRequest,
1074    InboxCompetitionInvite,
1075    InboxIncomingInvite,
1076    InboxOutgoingRequest,
1077    InboxRegistrationTodo,
1078    InboxResponse,
1079    InboxReviewQueue,
1080    InboxReviewVerdict,
1081    InboxRiskNotice,
1082    InboxRiskQueue,
1083    InboxSharedEnvReports,
1084    InboxTodoEntry,
1085    PromotionCandidateRow,
1086    PromotionRequest,
1087    PromotionResponse,
1088    TeamAffiliationSelection,
1089    TeamCaptainRequest,
1090    TeamDetail,
1091    TeamDisqualifyRequest,
1092    TeamInfo,
1093    TeamInviteInfo,
1094    TeamInvitePreview,
1095    TeamMemberInfo,
1096    TeamNameCheck,
1097    TeamStandingInfo,
1098)
1099from ctfy.server.models.user import (
1100    PROFILE_VISIBILITY_KEYS,
1101    AccountBanInfo,
1102    AccountBanRequest,
1103    AccountDeletionAccepted,
1104    AccountRestrictionInfo,
1105    AccountRestrictionRequest,
1106    AdminProfileResetRequest,
1107    AdminProfileResetResult,
1108    AdminSignInRemovalRequest,
1109    AdminSignInRemovalResult,
1110    AdminSignOutRequest,
1111    AdminSignOutResult,
1112    AdminUserInfo,
1113    CalendarBucket,
1114    DeleteMeRequest,
1115    DifficultyStat,
1116    MeResponse,
1117    OnboardingIntentRequest,
1118    ProfilePatchRequest,
1119    ProfileStats,
1120    ReauthenticateRequest,
1121    ReauthenticationOpened,
1122    SsoBindingInfo,
1123    TagStat,
1124    TrendPoint,
1125    UpdateUserRoleRequest,
1126    UpdateUserStandingsRequest,
1127    UserInfo,
1128    UserSchoolInfo,
1129    UserSolveTrend,
1130)
1131
1132__all__ = [
1133    "MAX_FLAGS_PER_SUBMISSION",
1134    "PREVIEW_TEAM_ROWS",
1135    "PROFILE_VISIBILITY_KEYS",
1136    "RUN_ARTIFACT_BLOCKED",
1137    "RUN_TRAFFIC_BLOCKED",
1138    "RUN_TRANSCRIPT_BLOCKED",
1139    "RUN_WALL_BLOCKED",
1140    "RUN_WRITEUP_BLOCKED",
1141    "TRANSCRIPT_REFUSALS",
1142    "WALL_REFUSALS",
1143    "AcceptTeamAgreementRequest",
1144    "AccountBanInfo",
1145    "AccountBanRequest",
1146    "AccountDeletionAccepted",
1147    "AccountKeyRow",
1148    "AccountRestrictionInfo",
1149    "AccountRestrictionRequest",
1150    "AchievementCatalogEntry",
1151    "AchievementCopy",
1152    "AchievementProgress",
1153    "AchievementSummary",
1154    "Activity",
1155    "ActivityTimeseries",
1156    "ActivityTimeseriesBucket",
1157    "AddEmailRequest",
1158    "AdminAddMemberRequest",
1159    "AdminArtifactStorageFlag",
1160    "AdminAuthoredChallengeDetail",
1161    "AdminAuthoredChallengeRow",
1162    "AdminAuthoredCurationRow",
1163    "AdminAuthoredRevisionRow",
1164    "AdminAuthoredVersionRow",
1165    "AdminChallengeLastError",
1166    "AdminChallengeLatencyBucket",
1167    "AdminChallengeQuestions",
1168    "AdminChallengeSolveStatsRow",
1169    "AdminChallengeStatsRow",
1170    "AdminChallengeTimeseries",
1171    "AdminChallengeTimeseriesBucket",
1172    "AdminChangelogEntryInfo",
1173    "AdminCompetitionAttachmentsResponse",
1174    "AdminFeedbackRow",
1175    "AdminHarnessRow",
1176    "AdminHealthFlags",
1177    "AdminInstanceInfo",
1178    "AdminInstanceTimingRow",
1179    "AdminInstanceTimings",
1180    "AdminInviteInfo",
1181    "AdminInviteStatus",
1182    "AdminMaliceBrief",
1183    "AdminMaliceInfo",
1184    "AdminMaliceRuleHit",
1185    "AdminMaliceStanding",
1186    "AdminModelReview",
1187    "AdminModelReviewBrief",
1188    "AdminModelReviewInfo",
1189    "AdminOverview",
1190    "AdminOverviewCounts",
1191    "AdminPatchSubmissionDetail",
1192    "AdminPatchSubmissionInfo",
1193    "AdminPatchSubmissionPage",
1194    "AdminProbeInfo",
1195    "AdminProfileResetRequest",
1196    "AdminProfileResetResult",
1197    "AdminRecentError",
1198    "AdminRepriceInfo",
1199    "AdminScoringChangeInfo",
1200    "AdminScoringChangesResponse",
1201    "AdminSignInRemovalRequest",
1202    "AdminSignInRemovalResult",
1203    "AdminSignOutRequest",
1204    "AdminSignOutResult",
1205    "AdminSilentChallenge",
1206    "AdminSolveCell",
1207    "AdminSolveMatrix",
1208    "AdminSolveMatrixChallenge",
1209    "AdminSolveMatrixTeam",
1210    "AdminStuckInstance",
1211    "AdminTaskInfo",
1212    "AdminTaskListResponse",
1213    "AdminTaskLogLine",
1214    "AdminTaskLogsResponse",
1215    "AdminTaskSubmitRequest",
1216    "AdminTeamRow",
1217    "AdminTimeseries",
1218    "AdminTimeseriesBucket",
1219    "AdminTimeseriesMetric",
1220    "AdminTokenRevokeResponse",
1221    "AdminTrafficInstanceRow",
1222    "AdminTrafficSummary",
1223    "AdminTrafficTeamRow",
1224    "AdminUnhealthyNode",
1225    "AdminUserInfo",
1226    "AffiliationInfo",
1227    "AffiliationSelection",
1228    "AgentActionRow",
1229    "AgentActionsPage",
1230    "AgentEntrantInfo",
1231    "AgentEvalBest",
1232    "AgentLogPage",
1233    "AgentPulseBeat",
1234    "AgentPulseLane",
1235    "AgentPulsePage",
1236    "AnnouncementCreate",
1237    "AnnouncementInfo",
1238    "AnnouncementUpdate",
1239    "AnswerTraceHit",
1240    "AnswerTraceRequest",
1241    "AnswerTraceResponse",
1242    "AppealReplyRequest",
1243    "AppealRequest",
1244    "ArchiveStoreReport",
1245    "AttachmentList",
1246    "AudiencePreview",
1247    "AudienceRequest",
1248    "AuditFinding",
1249    "AuthTokenResponse",
1250    "AuthorModelReview",
1251    "AuthorModelReviewInfo",
1252    "AuthorProbeInfo",
1253    "AuthorReputation",
1254    "AuthorVersionRow",
1255    "AuthoredActionEffectInfo",
1256    "AuthoredAudience",
1257    "AuthoredAudienceCompetition",
1258    "AuthoredAuditReport",
1259    "AuthoredAutoSmoke",
1260    "AuthoredBuildCapability",
1261    "AuthoredChallengeFileBody",
1262    "AuthoredChallengeFileEntry",
1263    "AuthoredChallengeFileRequest",
1264    "AuthoredChallengeInfo",
1265    "AuthoredChallengeListRow",
1266    "AuthoredChallengeReception",
1267    "AuthoredChallengeReviewRequest",
1268    "AuthoredChallengeRow",
1269    "AuthoredChangedFile",
1270    "AuthoredCheckProblem",
1271    "AuthoredCheckReport",
1272    "AuthoredCompetitionRow",
1273    "AuthoredCurationElsewhere",
1274    "AuthoredCurationState",
1275    "AuthoredDeclarations",
1276    "AuthoredFlow",
1277    "AuthoredFlowGraph",
1278    "AuthoredFlowLogEntry",
1279    "AuthoredFlowStateDef",
1280    "AuthoredFlowTransitionDef",
1281    "AuthoredFlowTransitionInfo",
1282    "AuthoredGraduationInfo",
1283    "AuthoredHistory",
1284    "AuthoredHistoryEntry",
1285    "AuthoredLifecycleRow",
1286    "AuthoredMaliceOverrideRequest",
1287    "AuthoredModelReviewOverrideRequest",
1288    "AuthoredOperatorEdit",
1289    "AuthoredOperatorRevisionInfo",
1290    "AuthoredOtherUpload",
1291    "AuthoredOtherUploads",
1292    "AuthoredPointsAtStake",
1293    "AuthoredProbeCell",
1294    "AuthoredQualityFlag",
1295    "AuthoredQueueCounts",
1296    "AuthoredReceptionRow",
1297    "AuthoredReceptionState",
1298    "AuthoredRepublishRequest",
1299    "AuthoredRevisionDiff",
1300    "AuthoredRevisionFileChange",
1301    "AuthoredRevisionInfo",
1302    "AuthoredRevisionRow",
1303    "AuthoredRunningCompetition",
1304    "AuthoredSameTitle",
1305    "AuthoredSmokeLine",
1306    "AuthoredSmokeLive",
1307    "AuthoredSmokeLiveStep",
1308    "AuthoredSmokeLog",
1309    "AuthoredSmokeLogSummary",
1310    "AuthoredSmokeStepLines",
1311    "AuthoredSmokeStepLog",
1312    "AuthoredStandardCheck",
1313    "AuthoredStrayRow",
1314    "AuthoredStripResult",
1315    "AuthoredUnpublishRequest",
1316    "AuthoredUploadAccepted",
1317    "AuthoredVersionDiff",
1318    "AuthoredVersionFileChange",
1319    "AuthoredVersionMessage",
1320    "AuthoredVersionMessageRequest",
1321    "AuthoredVersionRow",
1322    "AuthoredVersionStorageInfo",
1323    "AuthoredVersionSwitchInfo",
1324    "AuthoredVersionSwitchPlan",
1325    "AuthoredVersionSwitchRequest",
1326    "AuthoringChecklistInfo",
1327    "AuthoringChecklistRow",
1328    "AuthoringDivision",
1329    "AuthoringPauseInfo",
1330    "AuthoringQuotaInfo",
1331    "AuthoringRateInfo",
1332    "AuthoringReceptionInfo",
1333    "AuthoringReviewerInfo",
1334    "AuthoringScaleInfo",
1335    "AuthoringSmokeLedgerInfo",
1336    "AuthoringSmokeRunRow",
1337    "AuthoringTemplateFile",
1338    "AuthoringTemplateManifest",
1339    "AuthoringWindowInfo",
1340    "AuthoringWindowQuotaInfo",
1341    "AwardBandInfo",
1342    "AwdArenaGate",
1343    "AwdArenaGateLink",
1344    "AwdArenaLink",
1345    "AwdArenaPeerRow",
1346    "AwdArenaPeersResponse",
1347    "AwdAttackAnchorResponse",
1348    "AwdAttackAnchorRow",
1349    "AwdAttackMatrixResponse",
1350    "AwdAttackMatrixRow",
1351    "AwdBoardEntry",
1352    "AwdBoardResponse",
1353    "AwdCaptureCorroborationRow",
1354    "AwdClockBrief",
1355    "AwdCorroborationResponse",
1356    "AwdDefenceAnchorResponse",
1357    "AwdDefenceAnchorRow",
1358    "AwdDefenceTimelineCell",
1359    "AwdDefenceTimelineResponse",
1360    "AwdDefenceTimelineRow",
1361    "AwdEventRow",
1362    "AwdEventsResponse",
1363    "AwdFlagResult",
1364    "AwdFlagSubmitRequest",
1365    "AwdFlagSubmitResponse",
1366    "AwdForensicAnalysis",
1367    "AwdForensicsResponse",
1368    "AwdForensicsRow",
1369    "AwdMatchConfig",
1370    "AwdMatchInfo",
1371    "AwdMatchStatus",
1372    "AwdProvisionPlanInfo",
1373    "AwdRoundHistoryResponse",
1374    "AwdRoundPoint",
1375    "AwdRoundSeries",
1376    "AwdSlaHistoryResponse",
1377    "AwdSlaProbeRow",
1378    "AwdTarget",
1379    "AwdTargetsResponse",
1380    "AwdVpnPeer",
1381    "AwdVpnRequest",
1382    "AwdVpnStatus",
1383    "BatchAddMembersRequest",
1384    "BatchAddMembersResponse",
1385    "BatchMemberResultRow",
1386    "BigLimitOffsetPage",
1387    "BigLimitOffsetPageOf100",
1388    "BlueprintHostInfo",
1389    "BlueprintMilestoneInfo",
1390    "BoardCriterion",
1391    "BoardHarnessOption",
1392    "BoardRevision",
1393    "BoardTotals",
1394    "BoardVendorOption",
1395    "BuildPushScopeRequest",
1396    "BulkApproveRequest",
1397    "BulkApproveResponse",
1398    "BulkApproveResult",
1399    "BulkApproveTeam",
1400    "BulkCurationRequest",
1401    "BulkCurationResponse",
1402    "BulkCurationResult",
1403    "BulkCurationTarget",
1404    "BulkCurationUpload",
1405    "BulkInviteRequest",
1406    "BulkInviteResponse",
1407    "BulkInviteResultRow",
1408    "CalendarBucket",
1409    "CapacityWatchResult",
1410    "CaseCloseRequest",
1411    "ChallengeBlueprint",
1412    "ChallengeBuildKickoffNodeResult",
1413    "ChallengeBuildKickoffResponse",
1414    "ChallengeBuildNodeState",
1415    "ChallengeBuildStateResponse",
1416    "ChallengeBuildStateRow",
1417    "ChallengeDifficultyReading",
1418    "ChallengeEnvironment",
1419    "ChallengeFacetCount",
1420    "ChallengeFacets",
1421    "ChallengeFunnel",
1422    "ChallengeInfo",
1423    "ChallengeInternetRow",
1424    "ChallengePerformance",
1425    "ChallengePerformanceRow",
1426    "ChallengePullKickoffNodeResult",
1427    "ChallengePullKickoffResponse",
1428    "ChallengePullNodeState",
1429    "ChallengePullStateResponse",
1430    "ChallengePullStateRow",
1431    "ChallengeReceptionStats",
1432    "ChallengeRescanNodeResult",
1433    "ChallengeRescanResult",
1434    "ChallengeScheduleRow",
1435    "ChallengeScheduleUpdate",
1436    "ChallengeSolveAttempt",
1437    "ChallengeSolveAttemptsResponse",
1438    "ChallengeSourceCompetition",
1439    "ChallengeTcpAccessRow",
1440    "ChallengeTeamDetail",
1441    "ChallengeTeamRow",
1442    "ChallengeTeamSolveSummary",
1443    "ChallengeValuePoint",
1444    "ChangedSinceVerdict",
1445    "ChangelogAnnounceRequest",
1446    "ChangelogAnnounceResponse",
1447    "ChangelogEntryCreate",
1448    "ChangelogEntryInfo",
1449    "ChangelogEntryUpdate",
1450    "ChangelogImportEntry",
1451    "ChangelogImportRequest",
1452    "ChangelogImportResponse",
1453    "ChangelogLatestResponse",
1454    "ChangelogListResponse",
1455    "ChangelogSeenResponse",
1456    "CheckAnswerRequest",
1457    "CheckAnswerResponse",
1458    "ClusterInfo",
1459    "CompetitionAdminGrantRequest",
1460    "CompetitionAdminInfo",
1461    "CompetitionApplicationInfo",
1462    "CompetitionApplicationRequest",
1463    "CompetitionApplicationReviewRequest",
1464    "CompetitionApplicationRow",
1465    "CompetitionAttachmentInfo",
1466    "CompetitionAttachmentUpdate",
1467    "CompetitionAttachmentsResponse",
1468    "CompetitionBanRequest",
1469    "CompetitionBanRow",
1470    "CompetitionBoardCreate",
1471    "CompetitionBoardInfo",
1472    "CompetitionBoardUpdate",
1473    "CompetitionBoardsResponse",
1474    "CompetitionChallengeBreakdown",
1475    "CompetitionChallengeChangeInfo",
1476    "CompetitionChallengeOrder",
1477    "CompetitionChallengeRow",
1478    "CompetitionChallengeSchedule",
1479    "CompetitionChallengeSource",
1480    "CompetitionCloneRequest",
1481    "CompetitionCreate",
1482    "CompetitionDetail",
1483    "CompetitionDifferenceInfo",
1484    "CompetitionDriftInfo",
1485    "CompetitionEditionRef",
1486    "CompetitionEligibility",
1487    "CompetitionExclusion",
1488    "CompetitionFormatPreset",
1489    "CompetitionGroupRef",
1490    "CompetitionGroupSet",
1491    "CompetitionInfo",
1492    "CompetitionInternetApplied",
1493    "CompetitionInternetApplyRequest",
1494    "CompetitionInternetInfo",
1495    "CompetitionInviteInfo",
1496    "CompetitionMachineRow",
1497    "CompetitionMachines",
1498    "CompetitionMembershipInfo",
1499    "CompetitionPinnedChallenges",
1500    "CompetitionPoolInfo",
1501    "CompetitionPoolNode",
1502    "CompetitionPreviewChallenges",
1503    "CompetitionPrewarm",
1504    "CompetitionRecapMail",
1505    "CompetitionRecapMailUpdate",
1506    "CompetitionRecapSettings",
1507    "CompetitionRecapSettingsUpdate",
1508    "CompetitionRecords",
1509    "CompetitionRegistrationInfo",
1510    "CompetitionRoster",
1511    "CompetitionRosterTeam",
1512    "CompetitionScoreDistribution",
1513    "CompetitionScoreHistory",
1514    "CompetitionSeriesConfig",
1515    "CompetitionSeriesInfo",
1516    "CompetitionSessionEnd",
1517    "CompetitionSessionExtend",
1518    "CompetitionSessionInfo",
1519    "CompetitionTcpAccessInfo",
1520    "CompetitionTopicCreate",
1521    "CompetitionTopicInfo",
1522    "CompetitionTopicUpdate",
1523    "CompetitionTopicsResponse",
1524    "CompetitionUpdate",
1525    "ComposeRequest",
1526    "ComposeResult",
1527    "ConditionCellInfo",
1528    "ConditionMemberRow",
1529    "ConditionTeamResult",
1530    "ConditionUnmetItem",
1531    "ConditionsPreviewItem",
1532    "ConditionsPreviewTeam",
1533    "ContactOrganisersRequest",
1534    "ContributorRow",
1535    "CreateAdminInviteRequest",
1536    "CreateFineGrainedTokenRequest",
1537    "CreateFineGrainedTokenResponse",
1538    "CreateInviteRequest",
1539    "CreateInviteResponse",
1540    "CreateJoinCommandRequest",
1541    "CreateJoinCommandResponse",
1542    "CreditedChallengeRow",
1543    "DeceptionStats",
1544    "DefenceInfo",
1545    "DefenceLiveFile",
1546    "DefenceMatrixResponse",
1547    "DefenceMatrixRow",
1548    "DefenceSource",
1549    "DeleteMeRequest",
1550    "DeploymentConfig",
1551    "DeviceApproveRequest",
1552    "DeviceCodeResponse",
1553    "DeviceInfoResponse",
1554    "DeviceTokenRequest",
1555    "DeviceTokenResponse",
1556    "DifficultyStat",
1557    "DirectInviteSent",
1558    "EasterEggClaim",
1559    "EligibilityImpact",
1560    "EligibleInstitution",
1561    "EmailCodeAuthInfo",
1562    "EmailSuppressionCreate",
1563    "EmailSuppressionInfo",
1564    "EmailSuppressionListResponse",
1565    "EmailTemplateInfo",
1566    "EmailTemplateListResponse",
1567    "EmailVerificationSent",
1568    "EmailVerificationStatus",
1569    "EnvSharingChallengeRow",
1570    "EnvSharingGrouping",
1571    "EnvSharingUpdate",
1572    "EnvSharingView",
1573    "ErrorResponse",
1574    "EvalAllowanceGrant",
1575    "EvalAllowanceRow",
1576    "EvalArtifactBucket",
1577    "EvalArtifactReport",
1578    "EvalBatchCancelResult",
1579    "EvalBatchLimits",
1580    "EvalBatchModel",
1581    "EvalBatchRequest",
1582    "EvalBatchState",
1583    "EvalBoardScope",
1584    "EvalCampaignAtomicSummary",
1585    "EvalCancelRequest",
1586    "EvalEntryRequest",
1587    "EvalLeaderboard",
1588    "EvalMatchRoom",
1589    "EvalQueueChange",
1590    "EvalRangeFailureReport",
1591    "EvalRunCoverage",
1592    "EvalRunEnvironment",
1593    "EvalRunEvent",
1594    "EvalRunEventList",
1595    "EvalRunFailure",
1596    "EvalRunLlmActivity",
1597    "EvalRunPanel",
1598    "EvalRunQuestion",
1599    "EvalRunRangeFailure",
1600    "EvalRunSubmission",
1601    "EvalTrendPoint",
1602    "ExclusionConflictAccount",
1603    "ExclusionConflictInfo",
1604    "ExclusionConflictMember",
1605    "ExclusionConflictPerson",
1606    "ExclusionConflictReport",
1607    "ExclusionConflictSeat",
1608    "ExclusionGroupConflicts",
1609    "ExclusionGroupCreate",
1610    "ExclusionGroupInfo",
1611    "ExclusionGroupPage",
1612    "ExclusionGroupUpdate",
1613    "ExclusionNoticeChannel",
1614    "ExclusionNoticeItem",
1615    "ExclusionNoticeOutcome",
1616    "ExclusionNoticeRequest",
1617    "ExclusionNoticeResult",
1618    "ExclusionPeer",
1619    "FeaturedStatus",
1620    "FeaturedTrack",
1621    "FeedbackStats",
1622    "FirstBloodMove",
1623    "FleetReachAck",
1624    "FleetReachAckCleared",
1625    "FleetReachAckResult",
1626    "FleetReachEntry",
1627    "FleetReachGap",
1628    "FleetReachManifest",
1629    "FleetReachToken",
1630    "GauntletBuildCost",
1631    "GauntletBuildStep",
1632    "GauntletLibraryResponse",
1633    "GauntletModuleInfo",
1634    "GauntletPlanRequest",
1635    "GauntletPlanResponse",
1636    "GauntletPreset",
1637    "GauntletPreviewRequest",
1638    "GauntletPreviewResponse",
1639    "GauntletRangeCreate",
1640    "GauntletRangeCreated",
1641    "GauntletRangeInfo",
1642    "GauntletRangeListResponse",
1643    "GauntletShortfall",
1644    "GrantAuthoringReviewerRequest",
1645    "GrantCompetitionAdminRequest",
1646    "GroupRow",
1647    "GroupTeamRow",
1648    "GroupingDraw",
1649    "GroupingTable",
1650    "HarnessAdmission",
1651    "HarnessCreate",
1652    "HarnessExploit",
1653    "HarnessModelCell",
1654    "HarnessRegistryCredentialPut",
1655    "HarnessRegistryCredentialView",
1656    "HarnessStanding",
1657    "HarnessTags",
1658    "HarnessUpdate",
1659    "HealthResponse",
1660    "HintAttachmentUpload",
1661    "HintDraftBody",
1662    "HintEditorTier",
1663    "HintEditorView",
1664    "HintScenario",
1665    "HintViewResult",
1666    "HumanCheckRequirement",
1667    "ImageConsistencyRow",
1668    "InboxAnnouncement",
1669    "InboxApplicationQueue",
1670    "InboxAuthoringQueue",
1671    "InboxCaptainRequest",
1672    "InboxCompetitionInvite",
1673    "InboxFacet",
1674    "InboxIncomingInvite",
1675    "InboxItem",
1676    "InboxMachineNotice",
1677    "InboxOutgoingRequest",
1678    "InboxOverview",
1679    "InboxRecord",
1680    "InboxRegistrationTodo",
1681    "InboxReleaseNotice",
1682    "InboxResponse",
1683    "InboxReviewQueue",
1684    "InboxReviewVerdict",
1685    "InboxRiskNotice",
1686    "InboxRiskQueue",
1687    "InboxSharedEnvReports",
1688    "InboxTodoEntry",
1689    "InstanceAnswersResponse",
1690    "InstanceInfo",
1691    "InstanceInternetRequest",
1692    "InstanceInternetResult",
1693    "InstanceQuestionInfo",
1694    "InstanceRecordArtifacts",
1695    "InstanceRecordDetail",
1696    "InstanceRecordInfo",
1697    "InstanceRecordInfoDetail",
1698    "InstanceStatusResponse",
1699    "InstanceStorageReport",
1700    "InstanceTrafficPage",
1701    "InstanceTrafficWitnesses",
1702    "InstitutionCreate",
1703    "InstitutionInfo",
1704    "InstitutionPage",
1705    "InstitutionSuggestion",
1706    "InstitutionSuggestionPage",
1707    "IpLocationDatabase",
1708    "IpLocationStatus",
1709    "LandingDomain",
1710    "LandingEventWindow",
1711    "LandingFeatured",
1712    "LandingPrimarySuite",
1713    "LandingResponse",
1714    "LastRejection",
1715    "LeaderboardCell",
1716    "LimitOffsetPageUpTo200",
1717    "LimitOffsetPageUpTo500",
1718    "LinkStartResponse",
1719    "LinkedIdentity",
1720    "LlmAccountChoice",
1721    "LlmAccountSurface",
1722    "LlmBalanceLine",
1723    "LlmBalanceRefresh",
1724    "LlmBudgetPage",
1725    "LlmBudgetResetInfo",
1726    "LlmBudgetResetRequest",
1727    "LlmBudgetRow",
1728    "LlmCallBody",
1729    "LlmCallPage",
1730    "LlmCallRow",
1731    "LlmConnectionBalance",
1732    "LlmConnectionRef",
1733    "LlmConnectionUsage",
1734    "LlmLivePage",
1735    "LlmModelOption",
1736    "LlmMoney",
1737    "LlmProviderChoice",
1738    "LlmProviderCreate",
1739    "LlmProviderInfo",
1740    "LlmProviderPage",
1741    "LlmProviderProbe",
1742    "LlmRunUsage",
1743    "LlmSpendCap",
1744    "LlmSpendCapUpdate",
1745    "LlmSurfaceStatus",
1746    "LlmUsagePage",
1747    "LlmUsageTotals",
1748    "LoginRequest",
1749    "MachineAssignOutcome",
1750    "MachineAssignRequest",
1751    "MachineAssignResult",
1752    "MachineImportError",
1753    "MachineImportRequest",
1754    "MachineImportResult",
1755    "MachineNotifyRequest",
1756    "MachineNotifyResult",
1757    "MachineTeamOption",
1758    "MeResponse",
1759    "MessageSecrets",
1760    "MessageTeamOption",
1761    "MessageThreadDetail",
1762    "MessageThreadRow",
1763    "MetaAgents",
1764    "MetaAlert",
1765    "MetaAlerts",
1766    "MetaAuthoredArchive",
1767    "MetaChallenges",
1768    "MetaContribute",
1769    "MetaEvents",
1770    "MetaFreeze",
1771    "MetaInstanceTtl",
1772    "MetaLlm",
1773    "MetaMine",
1774    "MetaNode",
1775    "MetaNodePool",
1776    "MetaOps",
1777    "MetaPlatform",
1778    "MetaResponse",
1779    "MilestoneProgress",
1780    "ModelStanding",
1781    "MyAchievementsResponse",
1782    "MyEvalRunPage",
1783    "MyEvalRunRow",
1784    "MyFactVerdict",
1785    "MyFavourites",
1786    "MyHarness",
1787    "MyLlmConnection",
1788    "MyLlmConnectionCreate",
1789    "MyLlmKey",
1790    "MyLlmKeyCreate",
1791    "MyLlmKeyProbe",
1792    "MyReactionsResponse",
1793    "MyRegistrationResponse",
1794    "MyReportGrade",
1795    "MyReportGradesResponse",
1796    "MyReportRow",
1797    "MyReportsResponse",
1798    "MyResults",
1799    "MyReviewTimeline",
1800    "MyReviewTimelineEvent",
1801    "MySolveSummary",
1802    "MyTaskDetail",
1803    "MyTeamTodosResponse",
1804    "NodeAddress",
1805    "NodeAddressEcho",
1806    "NodeApproveRequest",
1807    "NodeAuthoredArchiveLimits",
1808    "NodeAuthoredPush",
1809    "NodeAuthoredResult",
1810    "NodeAuthoredSmokePush",
1811    "NodeAuthoredSmokeResult",
1812    "NodeAuthoredSyncLine",
1813    "NodeCapabilities",
1814    "NodeCaptureStoreReport",
1815    "NodeCaptureStoreRow",
1816    "NodeControlCheck",
1817    "NodeFeatureRow",
1818    "NodeFeatures",
1819    "NodeHeartbeat",
1820    "NodeHeartbeatResponse",
1821    "NodeInfo",
1822    "NodeInviteInfo",
1823    "NodePatch",
1824    "NodeRangePush",
1825    "NodeRangeResult",
1826    "NodeRangeSyncLine",
1827    "NodeRangeVerifyPush",
1828    "NodeRangeVerifyResult",
1829    "NodeReachProbe",
1830    "NodeReachReading",
1831    "NodeRegister",
1832    "NodeRegisterResponse",
1833    "NodeRegistryTicket",
1834    "NodeRegistryTicketRequest",
1835    "NodeSmokeEntrance",
1836    "NodeSmokeExploitOutcome",
1837    "NodeUdpCheck",
1838    "NotificationCategoryInfo",
1839    "NotificationChannelCell",
1840    "NotificationPreferenceUpdate",
1841    "NotificationPreferencesResponse",
1842    "OAuthProviderInfo",
1843    "OnboardingIntentRequest",
1844    "PairStanding",
1845    "PasswordAuthInfo",
1846    "PatchStepInfo",
1847    "PatchSubmissionCreate",
1848    "PatchSubmissionInfo",
1849    "PersonalisedColumn",
1850    "PersonalisedPreview",
1851    "PersonalisedPreviewItem",
1852    "PersonalisedRow",
1853    "PersonalisedSendRequest",
1854    "PlaneEnrollRequest",
1855    "PlaneEnrollResponse",
1856    "PlatformComposeRequest",
1857    "PlatformSettingInfo",
1858    "PlatformSettingPatch",
1859    "PlayerCapacity",
1860    "PlayerHintList",
1861    "PlayerHintStatus",
1862    "PlayerHintTier",
1863    "PlayerShellCreate",
1864    "PlayerShellTicket",
1865    "PrecheckInstanceInfo",
1866    "PrecheckQuestion",
1867    "PrecheckRerunFinding",
1868    "PrecheckRerunInfo",
1869    "PrecheckRerunStart",
1870    "PrecheckRerunSummary",
1871    "PrecheckRerunView",
1872    "PrecheckRound",
1873    "PrecheckRunRow",
1874    "PrecheckRunsInfo",
1875    "PrecheckSourceInfo",
1876    "PrecheckSubmission",
1877    "PrecheckSubmissionResult",
1878    "PrecheckSubmissionRow",
1879    "PrewarmNodeResult",
1880    "PrewarmRow",
1881    "PrewarmRunResult",
1882    "ProbeBrief",
1883    "ProbeModelChoice",
1884    "ProbeQuestionTally",
1885    "ProbeRun",
1886    "ProbeSmoke",
1887    "ProbeVersion",
1888    "ProfileAgent",
1889    "ProfileAgents",
1890    "ProfilePatchRequest",
1891    "ProfileResults",
1892    "ProfileStats",
1893    "PromotionCandidateRow",
1894    "PromotionRequest",
1895    "PromotionResponse",
1896    "ProvidersResponse",
1897    "PublicEvalDomainRate",
1898    "PublicEvalHour",
1899    "PublicEvalRun",
1900    "PublicEvalStats",
1901    "PublicFirstSolve",
1902    "PublicRecap",
1903    "PublicRecapReach",
1904    "PublicRoundedCount",
1905    "PublicScale",
1906    "PublicStatsResponse",
1907    "PublicStatusComponent",
1908    "PublicStatusResponse",
1909    "PublicTranscriptBlock",
1910    "PublicTranscriptResponse",
1911    "PublicTranscriptTurn",
1912    "PushSubscriptionCreate",
1913    "PushSubscriptionInfo",
1914    "PushSubscriptionKeys",
1915    "PushSubscriptionsResponse",
1916    "QaChallengeView",
1917    "QaSubmissionCreate",
1918    "QuestionAttemptResetInfo",
1919    "QuestionAttemptResetRequest",
1920    "QuestionCoverageRow",
1921    "QuestionPublicInfo",
1922    "RangeProgress",
1923    "RangeProgressRow",
1924    "RangeReplay",
1925    "RangeReplayFrame",
1926    "RangeReplayPosition",
1927    "ReactionCount",
1928    "ReadAllResult",
1929    "ReauthenticateRequest",
1930    "ReauthenticationOpened",
1931    "Recap",
1932    "RecapDueInfo",
1933    "RecapLinkReadRequest",
1934    "RecapLinkView",
1935    "RecapMailPreview",
1936    "RecapRecipientInfo",
1937    "RecapSendInfo",
1938    "RecentUnlock",
1939    "RecordAuthoredChallenge",
1940    "RecordChallengeChange",
1941    "RecordEvalRun",
1942    "RecordSolve",
1943    "RecordSubmission",
1944    "RecordTeam",
1945    "RegisterRequest",
1946    "RegistrationCapacity",
1947    "RegistrationCarsiEvidence",
1948    "RegistrationConditionCounts",
1949    "RegistrationConditionsPreview",
1950    "RegistrationEmailEvidence",
1951    "RegistrationFieldSpec",
1952    "RegistrationFormSpec",
1953    "RegistrationIdentityEvidence",
1954    "RegistrationMatch",
1955    "RegistrationMemberIdentityEvidence",
1956    "RegistrationMessageTemplate",
1957    "RegistrationMessageTemplateWrite",
1958    "RegistrationMessageTemplates",
1959    "RegistrationReviewNote",
1960    "RegistrationReviewNoteRequest",
1961    "RegistrationReviewNotes",
1962    "RegistrationReviewProgress",
1963    "RegistrationRosterRow",
1964    "RegistrationSubmitRequest",
1965    "RegistrationSummary",
1966    "RegistrationTeamDetail",
1967    "RegistrationTeamRow",
1968    "RegistrationTodoInfo",
1969    "RegistryGcStatus",
1970    "RegistryManifestRow",
1971    "RegistryPushTarget",
1972    "RegistryQuotaInfo",
1973    "RegistryReclaimItem",
1974    "RegistryReclaimPlan",
1975    "RegistryReclaimRequest",
1976    "RegistryRepo",
1977    "RegistryRepoList",
1978    "RegistryTagList",
1979    "RegistryUsageReport",
1980    "RegistryUsageRow",
1981    "ReleaseChallengeView",
1982    "ReleaseDraftBody",
1983    "ReleaseFindingInfo",
1984    "ReleaseHintMarker",
1985    "ReleasePlanView",
1986    "ReleaseStatus",
1987    "ReleaseSubjectBody",
1988    "ReleaseWaveCreate",
1989    "ReleaseWaveView",
1990    "RenderedReviewMessage",
1991    "RenewResponse",
1992    "ReplyRequest",
1993    "ReportAttachmentInfo",
1994    "ReportAttachmentsResponse",
1995    "ReportClaimRow",
1996    "ReportClaimsPage",
1997    "ReportGradeInfo",
1998    "ReportGradeSummary",
1999    "ReportGradesResponse",
2000    "ReportInfo",
2001    "ReportPipeline",
2002    "ReportPipelineEvent",
2003    "ReportRosterResponse",
2004    "ReportRosterRow",
2005    "ReportSaveRequest",
2006    "ReportSaveResponse",
2007    "ReportStanding",
2008    "ReportVersionsResponse",
2009    "RepriceInfo",
2010    "RepriceRequest",
2011    "RestoreResult",
2012    "RevealedAnswer",
2013    "ReviewDecisionRequest",
2014    "ReviewFieldDelta",
2015    "ReviewPresence",
2016    "ReviewPresenceBeat",
2017    "ReviewStaleDetail",
2018    "ReviewStaleError",
2019    "ReviewTimeline",
2020    "ReviewTimelineChange",
2021    "ReviewTimelineEvent",
2022    "ReviewTimelineTodo",
2023    "ReviewTodoItemRequest",
2024    "ReviewTodoRow",
2025    "ReviewVerdictInfo",
2026    "ReviewViewer",
2027    "ReviewerVerdictCount",
2028    "RiskActionRow",
2029    "RiskAppealList",
2030    "RiskAppealRow",
2031    "RiskAppealView",
2032    "RiskCaseDetail",
2033    "RiskCaseList",
2034    "RiskCaseRow",
2035    "RiskDeviceAccount",
2036    "RiskDeviceGraph",
2037    "RiskDeviceLink",
2038    "RiskEvidenceRow",
2039    "RiskNotice",
2040    "RiskSettings",
2041    "RiskTeamRef",
2042    "RiskTimeline",
2043    "RiskTimingPoint",
2044    "RunTranscriptRequest",
2045    "RunWallRequest",
2046    "RunWriteup",
2047    "ScheduledJobInfo",
2048    "ScheduledJobPatch",
2049    "SchoolIdentity",
2050    "ScopeCatalogEntry",
2051    "ScoreBucket",
2052    "ScoreHistoryPoint",
2053    "ScoreHistorySeries",
2054    "ScoreboardEntry",
2055    "ScoringChangeBoardRow",
2056    "ScoringChangeInfo",
2057    "ScoringChangesResponse",
2058    "ScoringManifestEntryInfo",
2059    "ScoringManifestInfo",
2060    "ScoringManifestPrice",
2061    "ScoringManifestRow",
2062    "ScoringPreview",
2063    "ScoringPreviewBlood",
2064    "ScoringPreviewChallenge",
2065    "ScoringPreviewRequest",
2066    "ScoringPreviewRow",
2067    "ScoringPreviewSeries",
2068    "SearchGroup",
2069    "SearchGroupName",
2070    "SearchHit",
2071    "SearchKind",
2072    "SearchResponse",
2073    "SearchScope",
2074    "SeriesOccurrencePreview",
2075    "SeriesPreview",
2076    "SetPasswordRequest",
2077    "SharedEnvKeyRequest",
2078    "SharedEnvReportResult",
2079    "SharedEnvRow",
2080    "SharedEnvTable",
2081    "ShellRecordingInfo",
2082    "SightingRow",
2083    "SightingsResponse",
2084    "SiteMessageView",
2085    "SittingResult",
2086    "SmokeQueueInfo",
2087    "SmokeRunTiming",
2088    "SolvePoint",
2089    "SolveRef",
2090    "SshCredential",
2091    "SshExchangeRequest",
2092    "SsoBindingInfo",
2093    "StandardCheckState",
2094    "StarGazerVerifyResponse",
2095    "StartRequest",
2096    "StartResponse",
2097    "StartWait",
2098    "StopResponse",
2099    "SubmissionCreate",
2100    "SubmissionResponse",
2101    "SuiteBestScore",
2102    "SuiteCategoryShare",
2103    "SuiteDifficulty",
2104    "SuiteInfo",
2105    "SuiteMatrixCell",
2106    "SuiteMatrixRow",
2107    "SuiteResults",
2108    "SuiteResultsRow",
2109    "TagStat",
2110    "TaskCard",
2111    "TaskCardCounts",
2112    "TaskCardList",
2113    "TaskCardSubject",
2114    "TeamAchievement",
2115    "TeamAffiliationSelection",
2116    "TeamAuthoringAgreementInfo",
2117    "TeamAuthoringAgreementStatus",
2118    "TeamCaptainRequest",
2119    "TeamConditionsVerdict",
2120    "TeamDetail",
2121    "TeamDisqualifyRequest",
2122    "TeamInfo",
2123    "TeamInviteInfo",
2124    "TeamInvitePreview",
2125    "TeamLogoUploadResponse",
2126    "TeamMachineLogin",
2127    "TeamMachineStatus",
2128    "TeamMemberInfo",
2129    "TeamNameChangeRow",
2130    "TeamNameCheck",
2131    "TeamPracticeSummary",
2132    "TeamSolveRow",
2133    "TeamStandingInfo",
2134    "TeamTodoProgressRow",
2135    "TestEmailRequest",
2136    "TestEmailResult",
2137    "ThreadEvent",
2138    "ThreadMember",
2139    "ThreadStatusRequest",
2140    "TokenInfo",
2141    "TokenLifetimeRequest",
2142    "TokenScopeUpdate",
2143    "TokenScopesResponse",
2144    "TokenTemplatesResponse",
2145    "TokenTraceLink",
2146    "TokenTraceRequest",
2147    "TokenTraceResponse",
2148    "TrafficWitness",
2149    "TrendPoint",
2150    "UnresolvedPin",
2151    "UpdateUserRoleRequest",
2152    "UpdateUserStandingsRequest",
2153    "UserAchievements",
2154    "UserEmailInfo",
2155    "UserInfo",
2156    "UserSchoolInfo",
2157    "UserSolveTrend",
2158    "VendorStandardSet",
2159    "VendorStanding",
2160    "VerifyAddedEmailRequest",
2161    "VerifyAnswerRequest",
2162    "VerifyAnswerResponse",
2163    "VerifyEmailRequest",
2164    "VirtualGhostBoard",
2165    "VirtualGhostRow",
2166    "VoidPreview",
2167    "VoidPreviewRequest",
2168    "VoidResult",
2169    "VoidSolvesRequest",
2170    "WaitlistBackfillRequest",
2171    "WaitlistBackfillResult",
2172    "WarnRequest",
2173    "WrongAnswer",
2174]
MAX_FLAGS_PER_SUBMISSION = 1000
PREVIEW_TEAM_ROWS = 20
PROFILE_VISIBILITY_KEYS = frozenset({'timezone', 'solves_count', 'tag_stats', 'email', 'bio', 'website_url', 'last_active_at', 'difficulty_stats', 'country', 'social_links', 'activity_calendar', 'achievements', 'rank_history', 'attempts_count', 'solve_trend', 'agents'})
RUN_ARTIFACT_BLOCKED = frozenset({'reclaimed', 'empty', 'not_yours', 'failed', 'not_configured', 'none'})
RUN_TRAFFIC_BLOCKED = frozenset({'reclaimed', 'not_yours', 'no_instance'})
RUN_TRANSCRIPT_BLOCKED = frozenset({'not_an_agent', 'not_yours', 'anonymous', 'no_entrant'})
RUN_WALL_BLOCKED = frozenset({'not_an_agent', 'not_yours', 'no_entrant'})
RUN_WRITEUP_BLOCKED = frozenset({'not_yours', 'none'})
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 AcceptTeamAgreementRequest(ctfy.core.models.CtfyModel):
2658class AcceptTeamAgreementRequest(CtfyModel):
2659    """The captain's acceptance of the agreement for the team (#1965 B).
2660
2661    ``terms_version`` is the version the captain read; a version the event
2662    no longer names is refused 409 ``authoring_terms_changed``, the page's
2663    *the terms moved while you were reading* case. The three declarations
2664    are required (``authored_declarations_incomplete`` names every one
2665    missing); the two grants default to their refusal.
2666    """
2667
2668    terms_version: str = Field(min_length=1, max_length=64)
2669    license: AuthoredLicenseState = ""
2670    provenance: AuthoredProvenanceState = ""
2671    #: ⚠️ ``str``: the scheme is asked in `declaration_refusals`, where it
2672    #: becomes a coded reason, as on the upload form.
2673    source_url: str = Field(default="", max_length=500)
2674    ai_assisted: AuthoredAiAssistanceState = ""
2675    eval_use: bool = False
2676    credit: AuthoredCredit = "anonymous"

The captain's acceptance of the agreement for the team (#1965 B).

terms_version is the version the captain read; a version the event no longer names is refused 409 authoring_terms_changed, the page's the terms moved while you were reading case. The three declarations are required (authored_declarations_incomplete names every one missing); the two grants default to their refusal.

terms_version: 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']] = ''
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'
class AccountBanInfo(ctfy.core.models.CtfyModel):
240class AccountBanInfo(CtfyModel):
241    """A platform-wide ban on one account (#2068), as an administrator reads it.
242
243    ``active`` is composed server-side from the clock, so a client never
244    compares ``expires_at`` with its own: the page and the refusal cannot
245    disagree about whether the ban is in force.
246    """
247
248    user_id: str
249    user_display_name: str = ""
250    reason: str = ""
251    banned_by: str = ""
252    banned_by_display_name: str = ""
253    banned_at: datetime | None = None
254    #: When the ban lapses by itself; ``None`` = until an administrator lifts it.
255    expires_at: datetime | None = None
256    lifted_by: str = ""
257    lifted_by_display_name: str = ""
258    lifted_at: datetime | None = None
259    active: bool = False

A platform-wide ban on one account (#2068), as an administrator reads it.

active is composed server-side from the clock, so a client never compares expires_at with its own: the page and the refusal cannot disagree about whether the ban is in force.

user_id: str = PydanticUndefined
user_display_name: str = ''
reason: str = ''
banned_by: str = ''
banned_by_display_name: str = ''
banned_at: datetime.datetime | None = None
expires_at: datetime.datetime | None = None
lifted_by: str = ''
lifted_by_display_name: str = ''
lifted_at: datetime.datetime | None = None
active: bool = False
class AccountBanRequest(ctfy.core.models.CtfyModel):
262class AccountBanRequest(CtfyModel):
263    """Body for ``POST /admin/users/{id}/ban``."""
264
265    # Same floor as every ruling here: it is what the person is shown and
266    # what an emailed appeal is argued from.
267    reason: str = Field(min_length=10, max_length=2000)
268    #: ``None`` bans until lifted.
269    expires_at: AwareDatetime | None = None

Body for POST /admin/users/{id}/ban.

reason: str = PydanticUndefined
expires_at: pydantic.types.AwareDatetime | None = None
class AccountDeletionAccepted(ctfy.core.models.CtfyModel):
652class AccountDeletionAccepted(CtfyModel):
653    """What ``DELETE /me`` did — carried out, or scheduled.
654
655    ⚠️ **A bare 204 could not say which**, and they are opposite facts
656    with opposite next steps: one reader has nothing left to do, the
657    other has a window in which signing in cancels the whole thing. That
658    is the shape ``make_probe`` and ``AttackOutcome`` both record — a
659    response the caller cannot act on differently is a response that
660    answered a different question.
661
662    ⚠️ **``deleted`` defaults ``False`` on both sides, and the asymmetry
663    is the argument.** A producer that forgets tells the caller their
664    account is *scheduled* when it is gone: they try to sign in, fail,
665    and learn the truth. The other default tells them it is *gone* when
666    it is recoverable, so they never try — and that mistake erases the
667    account by inaction, which is the one this whole feature exists to
668    prevent.
669    """
670
671    #: True when the cascade already ran inside the request — which is
672    #: what a grace of ``0`` means, and what this platform did before
673    #: the window existed.
674    deleted: bool = False
675    #: When the deletion becomes final. Equal to the request instant on
676    #: the ``deleted`` branch, so a reader never has to treat the two
677    #: cases as different shapes. Required: a default here would be a
678    #: new claim about an instant.
679    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):
753class AccountKeyRow(CtfyModel):
754    """One account's own credential, on the operator's merged page.
755
756    ⚠️ **No secret and no fingerprint**, unlike the fleet row above: four
757    characters of somebody else's credential buys the operator nothing —
758    they are reading *who pays*, not telling two of one person's keys
759    apart — and withholding them is what lets the whole listing be a
760    column projection that decrypts nothing. See
761    :class:`~ctfy.core.state.models.OwnedLlmKeySummary`.
762
763    ⚠️ **No Test button either, which is why the stored verdict is
764    here.** A probe spends somebody else's upstream account, so the
765    operator reads what the *owner's* last press established — with its
766    instant, because a code with no date reads as current for ever.
767
768    ⚠️ Served in a plain ``BigLimitOffsetPage``, deliberately not a
769    bespoke envelope like :class:`LlmProviderPage`: that one carries two
770    facts about the *deployment* (is there a gateway, can a secret be
771    sealed) which have nowhere else to ride. This carries none, and a
772    hand-written ``{items, total}`` would be a fourth page shape the
773    client's ``usePagination`` cannot read — it wants ``offset`` and
774    ``limit`` back as well, which is what tells a pager it is on the
775    last page rather than at the end of the list.
776    """
777
778    owner_user_id: str = ""
779    #: ``display_name or email``, composed server-side. ⚠️ ``""`` means
780    #: *no name to show* — an account that has gone — which is a
781    #: different fact from a blank display name, and the id beside it is
782    #: what keeps the row addressable either way.
783    owner_display_name: str = ""
784    flavor: FlavorOrUnset = ""
785    base_url: str = ""
786    label: str = ""
787    added_at: str = ""
788    #: ⚠️ ``""`` is *never spent by a run*, not "a while ago": for an
789    #: operator asking why an entrant's agent produced nothing, those are
790    #: opposite answers.
791    last_used_at: str = ""
792    last_probe_code: str = ""
793    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 AccountRestrictionInfo(ctfy.core.models.CtfyModel):
211class AccountRestrictionInfo(CtfyModel):
212    """One capability taken away from an account, in force now (#2774).
213
214    Only restrictions the clock says are in force are on the wire, so a
215    client never compares ``expires_at`` with its own time: the chip and the
216    refusal cannot disagree. Shown to the account itself (``GET /me``) as
217    well as to administrators -- the reason is the person's to read, as a
218    ban's is.
219    """
220
221    kind: RestrictionKind
222    reason: str = ""
223    created_at: datetime | None = None
224    #: When it lapses by itself; ``None`` = until an administrator lifts it.
225    expires_at: datetime | None = None

One capability taken away from an account, in force now (#2774).

Only restrictions the clock says are in force are on the wire, so a client never compares expires_at with its own time: the chip and the refusal cannot disagree. Shown to the account itself (GET /me) as well as to administrators -- the reason is the person's to read, as a ban's is.

kind: Literal['submit', 'hints', 'checker', 'defence', 'author', 'register', 'read_only'] = PydanticUndefined
reason: str = ''
created_at: datetime.datetime | None = None
expires_at: datetime.datetime | None = None
class AccountRestrictionRequest(ctfy.core.models.CtfyModel):
228class AccountRestrictionRequest(CtfyModel):
229    """Body for ``POST /admin/users/{id}/restrictions`` (#2774)."""
230
231    #: One or more kinds, imposed together with one reason and expiry. A
232    #: preset in the admin dialog is only a way of filling this list.
233    kinds: list[RestrictionKind] = Field(min_length=1, max_length=7)
234    # The ban's floor: the person reads it at every refusal.
235    reason: str = Field(min_length=10, max_length=2000)
236    #: Lapse by itself after this many days; ``0`` = until lifted.
237    days: int = Field(default=0, ge=0, le=MAX_RESTRICTION_DAYS)

Body for POST /admin/users/{id}/restrictions (#2774).

kinds: list[typing.Literal['submit', 'hints', 'checker', 'defence', 'author', 'register', 'read_only']] = PydanticUndefined
reason: str = PydanticUndefined
days: int = 0
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):
19class Activity(CtfyModel):
20    """A single activity event in the platform log."""
21
22    id: str = ""
23    timestamp: datetime | None = None
24    event: str  # See PlatformEvent enum for valid values
25    team_id: str = ""
26    team_name: str = ""
27    challenge_id: str = ""
28    # Who triggered this event — surfaced on the public feed so admins
29    # can filter by actor and auditors can trace back. Values mirror
30    # the ActivityState fields in core.state.models.
31    actor_id: str = ""
32    actor_name: str = ""
33    actor_type: str = ""
34    # Denormalised actor avatar (GitHub/Google), surfaced on the feed so
35    # each event can render the actor's real face. Empty ⇒ the frontend
36    # renders a generated initials chip instead.
37    actor_avatar_url: str = ""
38    detail: ActivityDetail = Field(default_factory=ActivityDetail)
39
40    @computed_field
41    @property
42    def ip_location(self) -> IpLocation | None:
43        """Where ``detail.ip`` is (#2140) — looked up now, never stored, so a
44        better database makes every historical row better. ``None`` when the
45        row carries no address. A ``computed_field`` because this is a wire
46        model (a state model would feed it to ``to_orm``)."""
47        if not self.detail.ip:
48            return None
49        # Imported here: the SDK imports this module and has no use for a database.
50        from ctfy.server.ip_location import locate_ip
51
52        return locate_ip(self.detail.ip)

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
ip_location: ctfy.core.ip_location.IpLocation | None
40    @computed_field
41    @property
42    def ip_location(self) -> IpLocation | None:
43        """Where ``detail.ip`` is (#2140) — looked up now, never stored, so a
44        better database makes every historical row better. ``None`` when the
45        row carries no address. A ``computed_field`` because this is a wire
46        model (a state model would feed it to ``to_orm``)."""
47        if not self.detail.ip:
48            return None
49        # Imported here: the SDK imports this module and has no use for a database.
50        from ctfy.server.ip_location import locate_ip
51
52        return locate_ip(self.detail.ip)

Where detail.ip is (#2140) — looked up now, never stored, so a better database makes every historical row better. None when the row carries no address. A computed_field because this is a wire model (a state model would feed it to to_orm).

class ActivityTimeseries(ctfy.core.models.CtfyModel):
70class ActivityTimeseries(CtfyModel):
71    """Bucketed activity counts for the per-team activity histogram.
72
73    Same ``ts`` semantics as ``AdminTimeseries`` — oldest-first, right
74    edge anchored to "now". ``events`` is the sorted union of event
75    types that appear in any bucket, so the frontend has a stable list
76    of stack segments to render even when individual buckets are empty.
77    """
78
79    window_s: int = 0
80    bucket_s: int = 0
81    events: list[str] = Field(default_factory=list)
82    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):
55class ActivityTimeseriesBucket(CtfyModel):
56    """One bucket of the team's activity histogram.
57
58    ``counts`` is keyed by event type (``flag_correct``, ``instance_started``,
59    …) so the frontend can stack each event as its own bar segment without
60    a follow-up shape-change to add new event types.
61
62    See :class:`AdminTimeseriesBucket` for the ``partial`` semantics.
63    """
64
65    ts: float = 0.0  # right edge, Unix seconds
66    counts: dict[str, int] = Field(default_factory=dict)
67    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):
251class AddEmailRequest(CtfyModel):
252    """Body for ``POST /me/emails`` — start proving a *new* address.
253
254    ⚠️ **This one does carry an address, and that is the whole
255    difference from ``VerifyEmailRequest``.** Its sibling refuses an
256    address field because the code there goes to the account's stored
257    mailbox and a parameter would let a caller mail anywhere and attach
258    anyone's address as proved. Here the code goes *to the address in
259    this body*, so it proves nothing unless the caller can read that
260    mailbox — the parameter is the act, not a bypass of it.
261    """
262
263    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 AdminAddMemberRequest(ctfy.core.models.CtfyModel):
711class AdminAddMemberRequest(CtfyModel):
712    """Body for an organiser putting a person into a competition directly —
713    no invitation for them to accept, no registration for them to fill in.
714
715    The person is named by ``user_id`` or (case-insensitive) ``email``, the
716    participation-invite route's shape; they must already have an account.
717    ``team_id`` places them on that existing team of this competition;
718    empty creates a new team with them as captain, named ``team_name`` or,
719    empty, the solo registration's default name.
720    """
721
722    user_id: str = ""
723    email: str = ""
724    team_id: str = ""
725    team_name: str = Field(default="", max_length=80)

Body for an organiser putting a person into a competition directly — no invitation for them to accept, no registration for them to fill in.

The person is named by user_id or (case-insensitive) email, the participation-invite route's shape; they must already have an account. team_id places them on that existing team of this competition; empty creates a new team with them as captain, named team_name or, empty, the solo registration's default name.

user_id: str = ''
email: str = ''
team_id: str = ''
team_name: str = ''
class AdminArtifactStorageFlag(ctfy.core.models.CtfyModel):
253class AdminArtifactStorageFlag(CtfyModel):
254    """The archive store is big enough that somebody should look.
255
256    ⚠️ **``verdict`` carries *why*, not just *that*.** "50 GB with a
257    30-day window" is a steady state somebody chose; "50 GB and nothing
258    reclaims it" is a ramp. The advice differs — shorten the window, or
259    set one at all — and a bare number cannot tell them apart.
260    """
261
262    #: ``growing`` = over the threshold with no retention window;
263    #: ``over`` = a window exists and is not keeping up.
264    verdict: str = ""
265    stored_runs: int = 0
266    stored_bytes: int = 0
267    alert_bytes: int = 0
268    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):
1906class AdminAuthoredChallengeDetail(CtfyModel):
1907    """One upload, opened.
1908
1909    The queue row plus what is actually in the tree. Reading one row is
1910    what makes carrying the file list affordable here and not on the
1911    listing.
1912    """
1913
1914    row: AdminAuthoredChallengeRow
1915    files: list[AuthoredChallengeFileEntry] = Field(default_factory=list)
1916    total_bytes: int = 0
1917    #: Whether publishing asks a platform admin to confirm they read the
1918    #: files marked ``executes`` (plan P2.3) — true exactly when any file
1919    #: is marked. ⚠️ Defaults ``False``, and the publish route asks the
1920    #: question itself, so an older client that sends no confirmation is
1921    #: refused with the reason rather than let through.
1922    needs_exec_review: bool = False
1923    #: #3337: an automatic review approved it, that policy release was
1924    #: withdrawn, and no person has released it since — so it starts
1925    #: nowhere until a person approves it (with the surface read). The
1926    #: launch's own question, never re-derived by the page.
1927    awaits_person_review: bool = False
1928    #: Every publication fact about this upload, oldest first (#1965 H).
1929    lifecycle: list[AuthoredLifecycleRow] = Field(default_factory=list)
1930    #: #2873: whether a build of this upload reaches the network — its
1931    #: competition's setting in force, which a smoke run uses unless the
1932    #: admin picks the other for that run.
1933    build_network: BuildNetwork = "online"
1934    #: #3110: the upload this one is merged into (its alias's survivor) —
1935    #: ``""`` when it is its own challenge. Read off the lifecycle on every
1936    #: detail, so ``""`` is a reading, never a default nobody computed.
1937    merged_into: str = ""
1938    #: #3110: the uploads merged into this one now.
1939    merged_here: list[str] = Field(default_factory=list)
1940
1941    #: #2787: question ids whose answer is static (free-form, single- or
1942    #: multi-select) — the same for every team, capped by the wrong-attempt
1943    #: budget. Read off the stored tree each time; ``[]`` is *every answer is
1944    #: minted*, which the door guarantees is never every question.
1945    static_questions: list[str] = Field(default_factory=list)

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
awaits_person_review: bool = False
lifecycle: list[AuthoredLifecycleRow] = PydanticUndefined
build_network: Literal['online', 'offline'] = 'online'
merged_into: str = ''
merged_here: list[str] = PydanticUndefined
static_questions: list[str] = PydanticUndefined
class AdminAuthoredChallengeRow(ctfy.server.models.AuthoredDeclarations):
1372class AdminAuthoredChallengeRow(AuthoredDeclarations):
1373    """One upload as the operator's review queue lists it.
1374
1375    ⚠️ **Composed server-side, and the reason is ``AdminHarnessRow``'s.**
1376    That queue "could not answer any of the three questions an approval
1377    turns on": the author was a uuid, the reviewer was a uuid, and the
1378    gate was recomputed. This one names both people, because deciding
1379    whether to run somebody's code starts with knowing whose it is —
1380    ``team_id`` is a uuid and a table of uuids is a table nobody triages.
1381
1382    ⚠️ **The tree is not here, and neither is its size.** The queue is
1383    over a whole event; the bytes and the file list belong to
1384    :class:`AdminAuthoredChallengeDetail`, which reads one row. See
1385    ``AuthoredChallengeSummary`` for why a *cheap* size column is worse
1386    than none.
1387
1388    ⚠️ ``/admin`` is outside `test_anonymity_doors`' scope on purpose —
1389    the organiser is precisely who sees through an anonymous entry — so
1390    ``team_name`` travels here where it may not on a board.
1391    """
1392
1393    id: str
1394    #: What the author called it. ⚠️ Unique per ``(competition_id,
1395    #: team_id)`` and no further — see `AuthoredChallengeRow.slug`.
1396    slug: str = ""
1397    #: ``metadata.yaml``'s own ``name:``, read at ingest. ``""`` means
1398    #: the metadata did not say; a reader falls back to `slug`, never
1399    #: to the id.
1400    title: str = ""
1401    competition_id: str
1402    #: ``""`` when the competition has been deleted out from under the
1403    #: row: ``authored_challenges`` is in ``_KEEP_ON_DELETE``, so a
1404    #: reviewer can still see what was submitted and to what.
1405    competition_title: str = ""
1406    #: The competition's title per locale (#2230); a browser renders this,
1407    #: ``competition_title`` is its base-locale reading.
1408    competition_title_i18n: LocalizedText = Field(default_factory=dict)
1409    team_id: str
1410    #: ⚠️ Blank means *this team is gone*, never *unnamed*. Rendered as
1411    #: the id rather than as an empty cell, or a deleted team's uploads
1412    #: read as somebody else's.
1413    team_name: str = ""
1414    user_id: str
1415    author_display_name: str = ""
1416    submitted_at: AwareDatetime | None = None
1417    review_status: ReviewStatus = "pending"
1418    #: The reviewing operator's id, ``""`` while nobody has ruled.
1419    reviewed_by: str = ""
1420    reviewed_by_display_name: str = ""
1421    reviewed_at: AwareDatetime | None = None
1422    review_note: str = ""
1423    #: Whether the tree is in this deployment's catalog, **read off the
1424    #: disk** rather than from a column.
1425    #:
1426    #: ⚠️ There is deliberately no ``published_at``, exactly as
1427    #: `GeneratedRangeState` has no status column: the directory is the
1428    #: fact, and a column beside it is a second claim free to disagree
1429    #: the moment anything removes the tree. ``review_status`` says what
1430    #: an operator decided; this says what is there.
1431    #:
1432    #: ⚠️ Defaults **False**, the loud direction: an older server or a
1433    #: producer that forgets reports *not published*, which costs a press
1434    #: that then refuses on the id. The other default would tell an
1435    #: operator their approved batch is live when nothing is on disk.
1436    in_catalog: bool = False
1437    #: Why the review verdict may not change now (#2740 D5): ``[]`` unless
1438    #: the upload is in use, when it names the step to take first. The
1439    #: verdict doors answer the same refusal (`authored_verdict`), so the
1440    #: page that disables the buttons and the press cannot disagree.
1441    verdict_refusals: list[AuthoringRefusal] = Field(default_factory=list)
1442    #: Where it stands with **this event's** challenge set — the last
1443    #: step of the chain, and the one nothing told the operator about.
1444    #:
1445    #: ⚠️ **Not ``in_catalog`` a second time.** That one is a fact about
1446    #: this deployment's disk; an approved, published, node-pushed,
1447    #: smoke-passing challenge that no organiser curated is live in the
1448    #: catalog and in front of nobody, and pays its author nothing.
1449    #: Measured before this field: ``not_curated`` reached the author on
1450    #: four surfaces and **zero** organiser surfaces — the review queue
1451    #: did not name ``challenge_ids`` at all.
1452    #:
1453    #: ⚠️ Defaults ``not_curated``, the direction that reads as
1454    #: unfinished: a producer that did not ask must not report an upload
1455    #: as being in front of entrants.
1456    curation: AuthoredCurationState = "not_curated"
1457    #: The competitions **other than this upload's own** whose curated
1458    #: set lists it — where it went after the event it was written for
1459    #: (plan P1.5). ``curation`` above stays the own-event answer.
1460    #:
1461    #: ⚠️ From the **same producer** for both audiences
1462    #: (`authoring_curation.curated_elsewhere`), `curation`'s rule — but
1463    #: the author's copy names only competitions the author may see, so
1464    #: an upload is not how a hidden event's name reaches somebody it is
1465    #: hidden from. The operator's copy names all of them.
1466    #:
1467    #: ⚠️ Empty by default, and empty is a real answer: nothing curated
1468    #: it anywhere else. A bucket competition never appears, because
1469    #: nothing is curated into a set resolved from a catalog directory.
1470    curated_elsewhere: list[AuthoredCurationElsewhere] = Field(default_factory=list)
1471    #: What the last smoke run on a worker found, ``""`` if none has run.
1472    #:
1473    #: ⚠️ **On the row rather than only in the task record**, and the
1474    #: reason is what an approval is for. An operator presses smoke and
1475    #: the verdict came back as a `TaskResult` — so reading it later
1476    #: meant finding that task, and re-reviewing a challenge meant
1477    #: taking somebody's word for whether it had ever been run. It is
1478    #: the same column the author reads; the two audiences act on it
1479    #: differently (fix it, versus decide about it) and neither can act
1480    #: without seeing it.
1481    #:
1482    #: ⚠️ **Usually ``""`` in this queue, and that is correct rather
1483    #: than useless.** Smoking needs an approved tree pushed to a node,
1484    #: so a row awaiting a first verdict has never been smoked — the
1485    #: blank is the honest state, and the column earns itself on the
1486    #: second look at a challenge rather than the first.
1487    smoke_verdict: AuthoredSmokeState = ""
1488    smoke_at: AwareDatetime | None = None
1489    #: The audit's verdict on the live tree (#2037 B1), from the per-process
1490    #: cache — ``unchecked`` while the platform has not run it yet.
1491    standard_check: AuthoredStandardCheck = Field(default_factory=AuthoredStandardCheck)
1492    #: `AuthoredChallengeRow.smoke_problem` (#1965 C3).
1493    smoke_problem: str = ""
1494    #: `AuthoredChallengeRow.tree_refusals` (#2113).
1495    tree_refusals: list[AuthoringRefusal] = Field(default_factory=list)
1496    #: A smoke run queued or running for this tree — see
1497    #: `AuthoredChallengeRow.smoke_in_flight`. What stops a reviewer
1498    #: pressing smoke for a run that is already on its way, and what the
1499    #: queue's ``in_flight`` filter selects.
1500    smoke_in_flight: AuthoredSmokeInFlightState = ""
1501    #: The platform admin who read this upload's execution surface at
1502    #: publish (plan P2.3), ``""`` if nobody has — including every upload
1503    #: published before the stamp existed, which the row does not claim.
1504    exec_reviewed_by: str = ""
1505    #: What to call ``exec_reviewed_by``; ``""`` beside a blank id.
1506    exec_reviewed_by_display_name: str = ""
1507    exec_reviewed_at: AwareDatetime | None = None
1508    #: **Released by policy** (#1965 C, decision 8): the competition whose
1509    #: automatic review let this tree run without a person reading its
1510    #: execution surface, and the dedicated pool it may run in; ``""`` /
1511    #: ``None`` for every upload a person released. ⚠️ Never the same fact
1512    #: as ``exec_reviewed_by`` — nobody read these files.
1513    exec_released_for: str = ""
1514    exec_released_pool: str = ""
1515    exec_released_at: AwareDatetime | None = None
1516    #: **Released to the public catalog** (#1989): who, and when. ``""`` /
1517    #: ``None`` is *not released* — published, the upload is seen only in
1518    #: the competitions that curate it. ``platform`` is the service account:
1519    #: the upload was public before the release was a verb (``0180``).
1520    catalog_released_by: str = ""
1521    #: What to call ``catalog_released_by``; ``""`` beside a blank id.
1522    catalog_released_by_display_name: str = ""
1523    catalog_released_at: AwareDatetime | None = None
1524    #: What the platform decided under automatic review (#1965 C) — see
1525    #: `AuthoredChallengeRow.automatic_outcome`. A ``held_*`` value is the
1526    #: queue's to act on: the platform would not decide, and said why.
1527    automatic_outcome: AutomaticOutcomeState = ""
1528    #: The model's reading of the live version (#1965 D), in brief; the whole
1529    #: review is ``GET …/model-review``. ⚠️ ``None`` is *no review taken*.
1530    model_review: AdminModelReviewBrief | None = None
1531    #: Why the safety check holds it (#3211), only beside a ``held_malice``
1532    #: outcome on the upload or its proposal; ``None`` otherwise.
1533    malice: AdminMaliceBrief | None = None
1534    #: Players' reports that reached the threshold (plan P2.6), in reading
1535    #: order. ⚠️ Empty is two facts — nobody reported enough, or the
1536    #: threshold is off — and both mean *nothing to act on here*, the only
1537    #: thing a queue reader asks of it.
1538    quality_flags: list[AuthoredQualityFlag] = Field(default_factory=list)
1539    #: The revision proposed for this upload and not yet applied, or
1540    #: ``None`` (plan P3.1) — what the queue's ``revision`` filter selects.
1541    revision: AdminAuthoredRevisionRow | None = None
1542    #: How players received this uploader's published challenges (plan
1543    #: P3.5), as this reader may attribute them. ``None`` is *nobody to
1544    #: read* — the uploader's account is gone — and never the same thing as
1545    #: a newcomer's reading, which says ``published: 0``.
1546    author_reputation: AuthorReputation | None = None
1547    #: How many boxes of this challenge are running right now (#2037) —
1548    #: what the take-down dialog counts before it asks whether to stop
1549    #: them. Live instances only, read off the platform's own dict, never
1550    #: the permanent history.
1551    running_instances: int = 0
1552    #: Who can do it right now (#2756), from the server's real visibility
1553    #: facts. ``None`` is *not asked*; the browser then says nothing.
1554    who_can_play: AuthoredAudience | None = None
1555    #: #3302: the upload this one is merged into now (#3110) — ``""`` when it
1556    #: is its own challenge. Read off the lifecycle for the whole page on
1557    #: every listing (`authored_merge.merge_facts`, the detail's producer), so
1558    #: ``""`` is a reading, never a default nobody computed. ⚠️ A ``pending``
1559    #: row with this set waits for no review: the decision was the merge.
1560    merged_into: str = ""
1561    #: #3302: the uploads merged into this one now, sorted.
1562    merged_here: list[str] = Field(default_factory=list)

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 = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
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
verdict_refusals: list[ctfy.core.authoring_refusals.AuthoringRefusal] = PydanticUndefined
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
standard_check: AuthoredStandardCheck = PydanticUndefined
smoke_problem: str = ''
tree_refusals: list[ctfy.core.authoring_refusals.AuthoringRefusal] = PydanticUndefined
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
exec_released_for: str = ''
exec_released_pool: str = ''
exec_released_at: pydantic.types.AwareDatetime | None = None
catalog_released_by: str = ''
catalog_released_by_display_name: str = ''
catalog_released_at: pydantic.types.AwareDatetime | None = None
automatic_outcome: Union[Literal[''], Literal['approved', 'rejected_unsolved', 'rejected_estate_failed', 'rejected_malformed', 'rejected_duplicate', 'rejected_injection', 'held_answers_not_minted', 'held_no_dedicated_pool', 'held_not_publishable', 'held_not_curatable', 'held_smoke_unavailable', 'held_malice']] = ''
model_review: AdminModelReviewBrief | None = None
malice: AdminMaliceBrief | None = None
quality_flags: list[AuthoredQualityFlag] = PydanticUndefined
revision: AdminAuthoredRevisionRow | None = None
author_reputation: AuthorReputation | None = None
running_instances: int = 0
who_can_play: AuthoredAudience | None = None
merged_into: str = ''
merged_here: list[str] = PydanticUndefined
class AdminAuthoredCurationRow(ctfy.server.models.AdminAuthoredChallengeRow):
1565class AdminAuthoredCurationRow(AdminAuthoredChallengeRow):
1566    """The queue row after a curation verb, and what the press was warned of.
1567
1568    ``warnings`` is what this competition would refuse at its upload door
1569    and the curation verb does not (#1965, follow-up 1): an organiser moving
1570    an upload with a static answer into a competition reviewed
1571    automatically is a person choosing, so they are told and not stopped.
1572    Empty for a removal, and for an addition nothing warned about.
1573    """
1574
1575    warnings: list[AuthoringRefusal] = Field(default_factory=list)

The queue row after a curation verb, and what the press was warned of.

warnings is what this competition would refuse at its upload door and the curation verb does not (#1965, follow-up 1): an organiser moving an upload with a static answer into a competition reviewed automatically is a person choosing, so they are told and not stopped. Empty for a removal, and for an addition nothing warned about.

warnings: list[ctfy.core.authoring_refusals.AuthoringRefusal] = PydanticUndefined
class AdminAuthoredRevisionRow(ctfy.server.models.AuthoredRevisionRow):
328class AdminAuthoredRevisionRow(AuthoredRevisionRow):
329    """A revision as a reviewer reads it: who ruled and who applied, named."""
330
331    #: The live version it was proposed against — the upload's
332    #: ``submitted_at`` at that moment.
333    base_submitted_at: AwareDatetime | None = None
334    reviewed_by: str = ""
335    reviewed_by_display_name: str = ""
336    applied_by: str = ""
337    applied_by_display_name: str = ""
338    #: The administrator who proposed it for its author (#3002), ``""`` for
339    #: the author's own.
340    revised_by: str = ""
341    revised_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 = ''
revised_by: str = ''
revised_by_display_name: str = ''
class AdminAuthoredVersionRow(ctfy.server.models.AuthoredVersionRow):
2146class AdminAuthoredVersionRow(AuthoredVersionRow):
2147    """`AuthoredVersionRow` for a reviewer, who also reads who ruled on it,
2148    who switched it live, and who revised it for its author."""
2149
2150    reviewed_by_display_name: str = ""
2151    switched_by_display_name: str = ""
2152    revised_by_display_name: str = ""
2153    #: Its newest AI pre-check (#3093), matched by the tree's digest;
2154    #: ``None`` is *none run*.
2155    ai_precheck: ProbeBrief | None = None

AuthoredVersionRow for a reviewer, who also reads who ruled on it, who switched it live, and who revised it for its author.

reviewed_by_display_name: str = ''
switched_by_display_name: str = ''
revised_by_display_name: str = ''
ai_precheck: ProbeBrief | None = None
class AdminChallengeLastError(ctfy.core.models.CtfyModel):
122class AdminChallengeLastError(CtfyModel):
123    """Most-recent error against a challenge, surfaced on the per-challenge
124    admin row so an admin can spot a broken challenge without opening
125    instance history."""
126
127    ts: float = 0.0  # stopped_at of the failing instance
128    message: str = ""
129    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):
197class AdminChallengeLatencyBucket(CtfyModel):
198    """One fixed bucket of the cluster-wide launch-duration histogram.
199
200    The Challenges admin table paginates server-side, so the pooled
201    "how slow are launches overall" picture can't be re-derived in the
202    browser from one page of rows — this endpoint pools every
203    challenge's recent successful launches and bins them with the same
204    absolute edges the per-row drawer uses, so cross-page the shape
205    stays comparable.
206    """
207
208    label: str
209    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):
311class AdminChallengeQuestions(CtfyModel):
312    """Every question a challenge declares, gated ones included.
313
314    The organiser's counterpart to the catalog projection, which omits
315    gated entries (see ``projections.question_infos``). Without this an
316    organiser running an exercise has no way to answer "which milestones
317    exist, and why did this team not get credit for one" short of reading
318    ``metadata.yaml`` off disk — and the corpus is a submodule the
319    deployment mounts read-only, not something the admin console can see.
320
321    Deliberately the **same** ``QuestionPublicInfo`` rows rather than a
322    wider admin model: this route changes *who may read*, not *what is
323    said*. The groundtruth is still never projected — a static ``answer``
324    lives in metadata and a dynamic one is minted per instance, and
325    neither belongs on a page. ``AdminTeamRow`` is a separate model
326    because it adds a field the public one must not carry; there is no
327    such field here.
328    """
329
330    challenge_id: str
331    questions: list[QuestionPublicInfo] = Field(default_factory=list)
332    # How many of ``questions`` are gated behind ``requires``. Served
333    # rather than derived so a client cannot disagree with the server
334    # about what the catalog is withholding.
335    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 AdminChallengeSolveStatsRow(ctfy.core.models.CtfyModel):
167class AdminChallengeSolveStatsRow(CtfyModel):
168    """One challenge inside one competition or suite, as its field played it (#2341, L14).
169
170    The retired global board's challenge view pooled every event into one
171    row per challenge; this is that view per scope, for organisers.
172    ``ctfy.core.scoped_solve_stats`` decides the figures.
173    """
174
175    challenge_id: str
176    name: str = ""
177    category: str = ""
178    competition_id: str
179    competition_title: str = ""
180    competition_title_i18n: LocalizedText = Field(default_factory=dict)
181    competition_kind: CompetitionKind = CompetitionKind.EVENT
182    #: Teams that captured every declared flag.
183    solvers: int = 0
184    #: Teams that submitted at least once (or solved).
185    attempting_teams: int = 0
186    submissions: int = 0
187    #: ``solvers / attempting_teams``; ``None`` when nobody attempted.
188    solve_rate: float | None = None
189    first_solver_team_id: str = ""
190    first_solver_name: str = ""
191    first_solved_at: datetime | None = None
192    #: Seconds from the competition's start to that first full solve;
193    #: ``None`` without a start (a suite) or without a solve.
194    first_solve_after_start_s: float | None = None

One challenge inside one competition or suite, as its field played it (#2341, L14).

The retired global board's challenge view pooled every event into one row per challenge; this is that view per scope, for organisers. ctfy.core.scoped_solve_stats decides the figures.

challenge_id: str = PydanticUndefined
name: str = ''
category: str = ''
competition_id: str = PydanticUndefined
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
competition_kind: ctfy.core.enums.CompetitionKind = <CompetitionKind.EVENT: 'event'>
solvers: int = 0
attempting_teams: int = 0
submissions: int = 0
solve_rate: float | None = None
first_solver_team_id: str = ''
first_solver_name: str = ''
first_solved_at: datetime.datetime | None = None
first_solve_after_start_s: float | None = None
class AdminChallengeStatsRow(ctfy.core.models.CtfyModel):
132class AdminChallengeStatsRow(CtfyModel):
133    """One row of the per-challenge admin dashboard.
134
135    Aggregates over ``InstanceRecord`` (lifetime) plus live ``InstanceState``
136    (running). Launch-duration percentiles are computed only over rows with
137    both ``requested_at`` and ``ready_at`` populated — error rows that never
138    reached READY contribute to ``errors`` / ``error_rate`` but not to p50/p95.
139
140    ⚠️ "Lifetime" was false here until the route grouped in SQL: every
141    figure came from the newest 10,000 records of the whole archive.
142    """
143
144    challenge_id: str
145    name: str = ""
146    difficulty: str = ""
147    # Spec metadata folded in so the admin table no longer has to join
148    # a second /challenges bulk fetch in the browser to show / sort /
149    # filter by these (it paginates server-side now).
150    category: str = ""
151    tags: list[str] = Field(default_factory=list)
152    question_count: int = 0
153    launches: int = 0
154    errors: int = 0
155    error_rate: float = 0.0
156    avg_launch_s: float = 0.0
157    p50_launch_s: float = 0.0
158    p95_launch_s: float = 0.0
159    solve_rate: float = 0.0
160    running_count: int = 0
161    last_error: AdminChallengeLastError | None = None
162    # Newest-first launch durations, capped at 20, used for the inline
163    # sparkline. Recorded only for successful launches.
164    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):
416class AdminChallengeTimeseries(CtfyModel):
417    """Per-challenge bucketed health metrics for the admin drilldown.
418
419    Backs the row-level drawer on /admin/challenges that surfaces "did
420    this challenge's error rate spike at some specific time" without
421    forcing the operator to scrub through raw activity.
422    """
423
424    challenge_id: str = ""
425    window_s: int = 0
426    bucket_s: int = 0
427    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):
397class AdminChallengeTimeseriesBucket(CtfyModel):
398    """One bucket of the per-challenge health time-series.
399
400    Carries enough fields for the admin UI to render error-rate **and**
401    launch-duration trends from a single response — no need for the
402    frontend to issue three calls or do its own bucketing.
403
404    See :class:`AdminTimeseriesBucket` for the ``partial`` semantics.
405    """
406
407    ts: float = 0.0  # right edge, Unix seconds
408    launches: int = 0
409    errors: int = 0
410    error_rate: float = 0.0  # errors / launches; 0 when no launches
411    p50_launch_s: float = 0.0
412    p95_launch_s: float = 0.0
413    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):
2555class AdminCompetitionAttachmentsResponse(CompetitionAttachmentsResponse):
2556    """The organiser's view: the files plus what the next upload may be."""
2557
2558    #: Whether this deployment can store a file at all. ⚠️ Served rather
2559    #: than inferred from an empty list, which reads as "nothing uploaded"
2560    #: when it means "nobody can".
2561    uploads_enabled: bool = False
2562    #: How many more files fit; ``None`` when the cap is ``0`` (unlimited).
2563    remaining: int | None = None
2564    #: The per-competition cap in force; ``0`` is unlimited.
2565    limit: int = 0
2566    #: The largest file accepted, in bytes — the hot setting as it
2567    #: resolves now, so the form states the number the route enforces.
2568    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):
633class AdminHarnessRow(CtfyModel):
634    """One registry row as the operator reads it.
635
636    The registry is self-service, so this table lists other people's
637    images. There is nothing to approve (#2614): an image runs once its
638    automatic admission passes. "Who uploaded this", "when", and "may it
639    run" are what the page answers, without a second lookup.
640
641    ⚠️ **A wire model, never ``HarnessState`` with fields bolted on.**
642    State models feed ``model_dump`` → ``to_orm``, which maps every
643    dumped key to a column — so a projected ``owner_display_name`` on
644    the state model is a fresh ``no such column`` the first time
645    anything writes the row back. The two projections live here, where
646    nothing persists them.
647
648    ⚠️ **Carried by the field set, never by a list of names** — see
649    ``admin_awd._info``, which named nine of the twelve fields it shared
650    with its state model and reported every armed match as unarmed.
651    """
652
653    # -- the stored row. Every field ``HarnessState`` declares, because
654    #    the admin route has always answered with the whole thing and
655    #    narrowing it here would be a silent removal from the page.
656    id: str = ""
657    display_name: str = ""
658    image: str = ""
659    version: str = ""
660    notes: str = ""
661    active: bool = True
662    created_at: AwareDatetime | None = None
663    created_at_ts: float = 0.0
664    created_by_user_id: str = ""
665    owner_user_id: str = ""
666    review_status: RegistrationReviewStatus = "pending"
667    reviewed_by_user_id: str = ""
668    reviewed_at_ts: float = 0.0
669    review_note: str = ""
670    approved_digest: str = ""
671    source_url: WebUrl = ""
672    conformance_status: ConformanceStatus = ""
673    conformance_checked_at_ts: float = 0.0
674    conformance_digest: str = ""
675    conformance_error: str = ""
676    conformance_findings: list[dict[str, str]] = Field(default_factory=list)
677    default_version_id: str = ""
678    versions_revoked_at_ts: float = 0.0
679    probe_status: ProbeStatus = ""
680    probe_digest: str = ""
681    probe_checked_at_ts: float = 0.0
682    probe_hardened: bool = False
683    image_user: str = ""
684    image_bytes: int = 0
685    probe_reason: ProbeReason = ""
686    probe_requested_at_ts: float = 0.0
687    probe_started_at_ts: float = 0.0
688    dialect: AgentDialect = ""
689
690    # -- what the row alone cannot say -------------------------------
691    #: #2608: 1-based place in the probe queue while ``queued``, else 0 —
692    #: read off the queue when the row is read, never stored.
693    probe_position: int = 0
694    #: The account that registered this image.
695    #:
696    #: ⚠️ ``""`` here is **not** one fact, and the pair is what
697    #: separates them: with ``owner_user_id`` also blank the row is the
698    #: platform's own and there is nobody to name; with an owner set,
699    #: the account is gone and the id is all that is left. A page that
700    #: read this field alone would label a deleted user's image
701    #: "platform image" — and a platform image is exempt from review,
702    #: which is the opposite of what a row whose author vanished should
703    #: read as.
704    owner_display_name: str = ""
705    #: Whether a user's current image may run (#2614: the only gate), from
706    #: the one decision the dispatch reads. Left at its default on a
707    #: platform row (``owner_user_id == ""``), which is ours and not admitted.
708    admission: HarnessAdmission = Field(default_factory=HarnessAdmission)

One registry row as the operator reads it.

The registry is self-service, so this table lists other people's images. There is nothing to approve (#2614): an image runs once its automatic admission passes. "Who uploaded this", "when", and "may it run" are what the page answers, without a second lookup.

⚠️ 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 0x74f512354e00>)] = ''
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
default_version_id: str = ''
versions_revoked_at_ts: float = 0.0
probe_status: Literal['', 'queued', 'running', 'passed', 'failed', 'error'] = ''
probe_digest: str = ''
probe_checked_at_ts: float = 0.0
probe_hardened: bool = False
image_user: str = ''
image_bytes: int = 0
probe_reason: Literal['', 'node_capacity', 'user_cap', 'global_cap', 'registry_refused', 'registry_auth_refused', 'image_too_large', 'pull_failed', 'did_not_start', 'image_refused', 'no_result', 'timeout', 'disk_budget', 'node_failed', 'interrupted'] = ''
probe_requested_at_ts: float = 0.0
probe_started_at_ts: float = 0.0
dialect: Union[Literal['anthropic_messages', 'openai_chat', 'openai_responses'], Literal['']] = ''
probe_position: int = 0
owner_display_name: str = ''
admission: HarnessAdmission = PydanticUndefined
class AdminHealthFlags(ctfy.core.models.CtfyModel):
271class AdminHealthFlags(CtfyModel):
272    """Aggregated red-flag panels for the admin Overview "needs attention"
273    section. Every list is independently populated and may be empty."""
274
275    stuck_starting: list[AdminStuckInstance] = Field(default_factory=list)
276    recent_errors: list[AdminRecentError] = Field(default_factory=list)
277    unhealthy_nodes: list[AdminUnhealthyNode] = Field(default_factory=list)
278    silent_challenges: list[AdminSilentChallenge] = Field(default_factory=list)
279    #: ⚠️ **A list of at most one**, to match its four neighbours rather
280    #: than make the panel special-case a scalar — and so the "is
281    #: anything red?" sum stays one expression.
282    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 AdminInstanceInfo(ctfy.server.models.InstanceInfo):
249class AdminInstanceInfo(InstanceInfo):
250    """An instance as an organiser reads it (``/admin/instances``).
251
252    ⚠️ The one place the group a shared box serves is named -- ``all`` or
253    ``g<N>`` -- and why it is a subclass: an admin route's model may carry
254    what a player's must not.
255    """
256
257    shared_group: str = ""

An instance as an organiser reads it (/admin/instances).

⚠️ The one place the group a shared box serves is named -- all or g<N> -- and why it is a subclass: an admin route's model may carry what a player's must not.

shared_group: str = ''
class AdminInstanceTimingRow(ctfy.core.models.CtfyModel):
352class AdminInstanceTimingRow(CtfyModel):
353    """One challenge's (or one node's) measured start timings (#2095).
354
355    ``total_*`` is what the player waited (``ready_at - requested_at``); the
356    ``queue`` / ``pull`` / ``build`` / ``healthy`` figures are where it went
357    -- the platform's queue wait, then the node's frozen-image pull, compose
358    build and ``up --wait``. Nearest-rank percentiles over **measured**
359    starts only (``starts``); a start the node did not time is not a
360    zero-second one and is not in them.
361    """
362
363    #: Challenge id, or node id, per the response's ``group_by``.
364    key: str = ""
365    #: The challenge's / node's display name; ``key`` when it no longer
366    #: resolves (a deleted challenge, a node that was removed).
367    name: str = ""
368    starts: int = 0
369    frozen_starts: int = 0
370    build_starts: int = 0
371    cache_starts: int = 0
372    none_starts: int = 0
373    total_p50_s: float = 0.0
374    total_p95_s: float = 0.0
375    queue_p50_s: float = 0.0
376    queue_p95_s: float = 0.0
377    pull_p50_s: float = 0.0
378    pull_p95_s: float = 0.0
379    build_p50_s: float = 0.0
380    build_p95_s: float = 0.0
381    healthy_p50_s: float = 0.0
382    healthy_p95_s: float = 0.0

One challenge's (or one node's) measured start timings (#2095).

total_* is what the player waited (ready_at - requested_at); the queue / pull / build / healthy figures are where it went -- the platform's queue wait, then the node's frozen-image pull, compose build and up --wait. Nearest-rank percentiles over measured starts only (starts); a start the node did not time is not a zero-second one and is not in them.

key: str = ''
name: str = ''
starts: int = 0
frozen_starts: int = 0
build_starts: int = 0
cache_starts: int = 0
none_starts: int = 0
total_p50_s: float = 0.0
total_p95_s: float = 0.0
queue_p50_s: float = 0.0
queue_p95_s: float = 0.0
pull_p50_s: float = 0.0
pull_p95_s: float = 0.0
build_p50_s: float = 0.0
build_p95_s: float = 0.0
healthy_p50_s: float = 0.0
healthy_p95_s: float = 0.0
class AdminInstanceTimings(ctfy.core.models.CtfyModel):
385class AdminInstanceTimings(CtfyModel):
386    """``GET /admin/instance-timings`` -- slowest first (by total p95)."""
387
388    group_by: TimingGroup
389    #: The filters this answer was read under, echoed so a page or a CLI
390    #: table can say what it is showing.
391    competition_id: str = ""
392    window_s: int = 0
393    image_kind: StartImageKind = ""
394    rows: list[AdminInstanceTimingRow] = Field(default_factory=list)

GET /admin/instance-timings -- slowest first (by total p95).

group_by: Literal['challenge', 'node'] = PydanticUndefined
competition_id: str = ''
window_s: int = 0
image_kind: Literal['', 'frozen', 'build', 'cache', 'none'] = ''
rows: list[AdminInstanceTimingRow] = PydanticUndefined
class AdminInviteInfo(ctfy.core.models.CtfyModel):
39class AdminInviteInfo(CtfyModel):
40    """One administrator invitation, for the super-admin cards."""
41
42    id: str
43    email: str
44    kind: AdminInviteKind
45    competition_id: str = ""
46    role: CompetitionGrantRole = "admin"
47    status: AdminInviteStatus
48    invited_by: str = ""
49    invited_by_display_name: str = ""
50    invited_at: AwareDatetime | None = None
51    expires_at: AwareDatetime | None = None
52    claimed_by_user_id: str = ""
53    claimed_at: AwareDatetime | None = None

One administrator invitation, for the super-admin cards.

id: str = PydanticUndefined
email: str = PydanticUndefined
kind: Literal['platform_admin', 'competition_admin', 'authoring_reviewer'] = PydanticUndefined
competition_id: str = ''
role: Literal['admin', 'reviewer'] = 'admin'
status: Literal['pending', 'claimed', 'expired'] = PydanticUndefined
invited_by: str = ''
invited_by_display_name: str = ''
invited_at: pydantic.types.AwareDatetime | None = None
expires_at: pydantic.types.AwareDatetime | None = None
claimed_by_user_id: str = ''
claimed_at: pydantic.types.AwareDatetime | None = None
AdminInviteStatus = typing.Literal['pending', 'claimed', 'expired']
class AdminMaliceBrief(ctfy.core.models.CtfyModel):
691class AdminMaliceBrief(CtfyModel):
692    """Why the safety check holds a queue row, in brief (#3211 UI): the
693    queue's 「为什么等人」 cell. The whole evidence is ``GET …/malice-review``.
694    ⚠️ **Organisers and reviewers only** (D6), like the standing it is cut
695    from; an author is told one status word."""
696
697    #: ``""`` is the upload's own tree; otherwise the held proposal's id.
698    revision_id: str = ""
699    hold: bool
700    reasons: list[MaliceReason] = Field(default_factory=list)
701    #: Layer-① hits of severity ``hold`` (the ``flag`` ones hold nothing).
702    rule_hold_count: int = 0
703    verdict: MaliceVerdict | None = None
704    problem: MaliceProblem = ""
705    override_decision: MaliceOverride = ""

Why the safety check holds a queue row, in brief (#3211 UI): the queue's 「为什么等人」 cell. The whole evidence is GET …/malice-review. ⚠️ Organisers and reviewers only (D6), like the standing it is cut from; an author is told one status word.

revision_id: str = ''
hold: bool = PydanticUndefined
reasons: list[typing.Literal['rule_hold', 'rule_unread', 'model_harmful', 'model_exec_unread', 'model_data_unread', 'model_unavailable', 'model_ungrounded', 'model_unasked', 'behaviour']] = PydanticUndefined
rule_hold_count: int = 0
verdict: Optional[Literal['clean', 'harmful', 'unread', 'unknown']] = None
problem: Literal['', 'exec_unread', 'data_unread', 'no_gateway', 'no_dialect', 'unreachable', 'unreadable', 'ungrounded', 'failed'] = ''
override_decision: Literal['', 'clear', 'confirm'] = ''
class AdminMaliceInfo(ctfy.core.models.CtfyModel):
708class AdminMaliceInfo(CtfyModel):
709    """The malice check of one upload's live tree and of its proposed
710    revision (``None`` while there is no proposal)."""
711
712    live: AdminMaliceStanding
713    revision: AdminMaliceStanding | None = None

The malice check of one upload's live tree and of its proposed revision (None while there is no proposal).

live: AdminMaliceStanding = PydanticUndefined
revision: AdminMaliceStanding | None = None
class AdminMaliceRuleHit(ctfy.core.models.CtfyModel):
629class AdminMaliceRuleHit(CtfyModel):
630    """One layer-① rule's finding (#3211), located by the platform."""
631
632    rule_id: str
633    #: ``hold`` holds the version; ``flag`` is shown and holds nothing.
634    severity: Literal["hold", "flag"]
635    path: str
636    line: int
637    quote: str

One layer-① rule's finding (#3211), located by the platform.

rule_id: str = PydanticUndefined
severity: Literal['hold', 'flag'] = PydanticUndefined
path: str = PydanticUndefined
line: int = PydanticUndefined
quote: str = PydanticUndefined
class AdminMaliceStanding(ctfy.core.models.CtfyModel):
640class AdminMaliceStanding(CtfyModel):
641    """One version's malice check, whole, for the organisers (#3211 PR3).
642
643    ``hold`` is the platform's answer (`ctfy.core.malice`) and ``reasons``
644    every reason the evidence holds — kept after a person cleared it, as the
645    record of what they released. ⚠️ **Organisers and reviewers only** (D6):
646    an author is told one status word. ``review_id`` is ``""`` while no
647    model reading of this version exists (``model_unasked``); an override
648    names the reading by it.
649    """
650
651    revision_id: str = ""
652    subject_submitted_at: AwareDatetime | None = None
653    hold: bool
654    reasons: list[MaliceReason] = Field(default_factory=list)
655    rule_hits: list[AdminMaliceRuleHit] = Field(default_factory=list)
656    #: Executed shell the rules could not read.
657    rules_unread: list[str] = Field(default_factory=list)
658    review_id: str = ""
659    verdict: MaliceVerdict | None = None
660    problem: MaliceProblem = ""
661    reasoning: str = ""
662    evidence: list[MaliceEvidenceItem] = Field(default_factory=list)
663    unread_paths: list[str] = Field(default_factory=list)
664    unread_count: int = 0
665    bulk: list[MaliceBulkItem] = Field(default_factory=list)
666    bulk_count: int = 0
667    #: How many files the model was shown whole; with ``unread_count`` and
668    #: ``bulk_count`` it is the tree the reading covered (#3211 UI).
669    files_read: int = 0
670    model: str = ""
671    reviewed_at: AwareDatetime | None = None
672    override_decision: MaliceOverride = ""
673    overridden_by: str = ""
674    overridden_by_display_name: str = ""
675    overridden_at: AwareDatetime | None = None
676    override_note: str = ""
677    #: #3247: this version was released from a hold and its smoke is waiting
678    #: for the event's **isolated** node — none is live. It never runs on the
679    #: event's own nodes; an operator adds a node labelled
680    #: ``competition=<id>`` and ``quarantine_builds=true``.
681    smoke_waits_for_isolated_node: bool = False
682    #: #3211 PR4, layer ③: what the version's newest watched smoke saw its
683    #: estate do. ⚠️ ``None`` is *not reported* (no watched run yet, or an
684    #: older node) — the page says *没有数据*, never *没发现*.
685    behaviour: SmokeBehaviour | None = None
686    #: The readings over a threshold (`core.smoke_behaviour.behaviour_hits`);
687    #: any one holds the version (reason ``behaviour``). Empty with no record.
688    behaviour_hits: list[BehaviourHit] = Field(default_factory=list)

One version's malice check, whole, for the organisers (#3211 PR3).

hold is the platform's answer (ctfy.core.malice) and reasons every reason the evidence holds — kept after a person cleared it, as the record of what they released. ⚠️ Organisers and reviewers only (D6): an author is told one status word. review_id is "" while no model reading of this version exists (model_unasked); an override names the reading by it.

revision_id: str = ''
subject_submitted_at: pydantic.types.AwareDatetime | None = None
hold: bool = PydanticUndefined
reasons: list[typing.Literal['rule_hold', 'rule_unread', 'model_harmful', 'model_exec_unread', 'model_data_unread', 'model_unavailable', 'model_ungrounded', 'model_unasked', 'behaviour']] = PydanticUndefined
rule_hits: list[AdminMaliceRuleHit] = PydanticUndefined
rules_unread: list[str] = PydanticUndefined
review_id: str = ''
verdict: Optional[Literal['clean', 'harmful', 'unread', 'unknown']] = None
problem: Literal['', 'exec_unread', 'data_unread', 'no_gateway', 'no_dialect', 'unreachable', 'unreadable', 'ungrounded', 'failed'] = ''
reasoning: str = ''
evidence: list[ctfy.core.state.models.MaliceEvidenceItem] = PydanticUndefined
unread_paths: list[str] = PydanticUndefined
unread_count: int = 0
bulk: list[ctfy.core.state.models.MaliceBulkItem] = PydanticUndefined
bulk_count: int = 0
files_read: int = 0
model: str = ''
reviewed_at: pydantic.types.AwareDatetime | None = None
override_decision: Literal['', 'clear', 'confirm'] = ''
overridden_by: str = ''
overridden_by_display_name: str = ''
overridden_at: pydantic.types.AwareDatetime | None = None
override_note: str = ''
smoke_waits_for_isolated_node: bool = False
behaviour: ctfy.core.smoke_behaviour.SmokeBehaviour | None = None
behaviour_hits: list[typing.Literal['fence', 'internet', 'processes', 'cpu']] = PydanticUndefined
class AdminModelReview(ctfy.core.models.CtfyModel):
362class AdminModelReview(CtfyModel):
363    """One model review, whole, for the organisers.
364
365    ``injection`` is what the model said and ``effective_injection`` what
366    the platform acts on — ``clean`` once a person overrode an
367    ``injection``. ``reasoning`` and ``evidence`` (spans quoted verbatim
368    from the named file) are the organisers' alone. ``near_duplicates`` are
369    the uploads of this competition and the catalog directories the tree
370    resembles, best first — advice, never a refusal.
371    """
372
373    id: str
374    #: ``""`` for the upload's own tree; otherwise the revision read.
375    revision_id: str = ""
376    subject_submitted_at: AwareDatetime | None = None
377    injection: ModelReviewInjection = "unknown"
378    effective_injection: ModelReviewInjection = "unknown"
379    problem: ModelReviewProblem = ""
380    reasoning: str = ""
381    evidence: list[ModelReviewEvidenceItem] = Field(default_factory=list)
382    findings: list[ModelReviewFindingItem] = Field(default_factory=list)
383    near_duplicates: list[NearDuplicateItem] = Field(default_factory=list)
384    model: str = ""
385    chars_read: int = 0
386    files_read: int = 0
387    binary_files: int = 0
388    reviewed_at: AwareDatetime | None = None
389    blocked: bool = False
390    overridden_by: str = ""
391    overridden_by_display_name: str = ""
392    overridden_at: AwareDatetime | None = None
393    override_note: str = ""

One model review, whole, for the organisers.

injection is what the model said and effective_injection what the platform acts on — clean once a person overrode an injection. reasoning and evidence (spans quoted verbatim from the named file) are the organisers' alone. near_duplicates are the uploads of this competition and the catalog directories the tree resembles, best first — advice, never a refusal.

id: str = PydanticUndefined
revision_id: str = ''
subject_submitted_at: pydantic.types.AwareDatetime | None = None
injection: Literal['clean', 'injection', 'unknown'] = 'unknown'
effective_injection: Literal['clean', 'injection', 'unknown'] = 'unknown'
problem: Literal['', 'no_gateway', 'no_dialect', 'unreachable', 'unreadable', 'ungrounded', 'incomplete', 'failed'] = ''
reasoning: str = ''
evidence: list[ctfy.core.state.models.ModelReviewEvidenceItem] = PydanticUndefined
findings: list[ctfy.core.state.models.ModelReviewFindingItem] = PydanticUndefined
near_duplicates: list[ctfy.core.state.models.NearDuplicateItem] = PydanticUndefined
model: str = ''
chars_read: int = 0
files_read: int = 0
binary_files: int = 0
reviewed_at: pydantic.types.AwareDatetime | None = None
blocked: bool = False
overridden_by: str = ''
overridden_by_display_name: str = ''
overridden_at: pydantic.types.AwareDatetime | None = None
override_note: str = ''
class AdminModelReviewBrief(ctfy.core.models.CtfyModel):
349class AdminModelReviewBrief(CtfyModel):
350    """A model review as the review queue lists it: the verdict and counts."""
351
352    injection: ModelReviewInjection = "unknown"
353    problem: ModelReviewProblem = ""
354    finding_count: int = 0
355    near_duplicate_count: int = 0
356    #: This verdict rejected the upload (an automatic competition).
357    blocked: bool = False
358    #: A person said the flagged text is not an injection.
359    overridden: bool = False

A model review as the review queue lists it: the verdict and counts.

injection: Literal['clean', 'injection', 'unknown'] = 'unknown'
problem: Literal['', 'no_gateway', 'no_dialect', 'unreachable', 'unreadable', 'ungrounded', 'incomplete', 'failed'] = ''
finding_count: int = 0
near_duplicate_count: int = 0
blocked: bool = False
overridden: bool = False
class AdminModelReviewInfo(ctfy.core.models.CtfyModel):
427class AdminModelReviewInfo(CtfyModel):
428    """The model's readings of one upload: its live tree and its proposed
429    revision, each ``None`` while none was taken, and whether a review an
430    organiser asked for is still queued or running."""
431
432    review: AdminModelReview | None = None
433    revision_review: AdminModelReview | None = None
434    in_flight: bool = False

The model's readings of one upload: its live tree and its proposed revision, each None while none was taken, and whether a review an organiser asked for is still queued or running.

review: AdminModelReview | None = None
revision_review: AdminModelReview | None = None
in_flight: bool = False
class AdminOverview(ctfy.core.models.CtfyModel):
446class AdminOverview(CtfyModel):
447    """Single aggregate payload for the admin landing page."""
448
449    nodes: AdminOverviewCounts = Field(default_factory=AdminOverviewCounts)
450    config: DeploymentConfig = Field(default_factory=DeploymentConfig)
451    capacity: int = 0
452    #: Live boxes that are running -- held ones excluded (#2128).
453    running_instances: int = 0
454    #: Live boxes a session boundary holds (paused / stopped until the next
455    #: session). Not running, but each still occupies a slot, so the slots in
456    #: use against ``capacity`` are ``running_instances + held_instances``.
457    held_instances: int = 0
458    teams_total: int = 0
459    solves_total: int = 0
460    solves_today: int = 0
461    # User-engagement counters — the "are people actually on the platform right
462    # now?" signal the infra/challenge/team metrics above don't answer.
463    # ``users_active_24h`` is distinct humans who triggered a write action
464    # (launch / submit / solve / join / answer) in the last 24h, not raw logins
465    # (pure browsing leaves no activity row). ``users_new_24h`` is registrations
466    # in the same window. All default to 0 so the field set stays additive.
467    users_total: int = 0
468    users_active_24h: int = 0
469    users_new_24h: int = 0
470    #: Accounts whose deletion deadline has passed and which are still
471    #: here — i.e. what only a stopped ``account_deletion`` job
472    #: produces.
473    #:
474    #: ⚠️ **Overdue, never *pending*.** Every scheduled deletion is
475    #: pending until its deadline, so a pending count is healthy by
476    #: construction and says nothing about whether the sweep runs. This
477    #: is the product-side reading ``registry_usage_scan`` records the
478    #: need for: *a job's self-report is not a health signal for what it
479    #: produces*, and that job reported ``ok`` hourly for the life of a
480    #: deployment over its own failure.
481    #:
482    #: ⚠️ Defaults ``0``, which is the quiet direction — and that is the
483    #: right one here only because the alarming direction is a *standing*
484    #: claim: a producer that forgot would otherwise post a permanent
485    #: "the job is broken" banner on every healthy deployment, which is
486    #: how an operator learns to ignore it. A real stall grows the number
487    #: every tick, so the miss costs latency rather than the signal.
488    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
held_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):
117class AdminOverviewCounts(CtfyModel):
118    total: int = 0
119    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):
1630class AdminPatchSubmissionDetail(AdminPatchSubmissionInfo):
1631    """One submission with the code it carried.
1632
1633    This is what keeping the bytes was *for*: a team disputes a verdict,
1634    or a checker bug is found on day two, and someone has to read what
1635    was actually submitted. Without a reader the stored blobs bought
1636    nothing.
1637    """
1638
1639    #: ``{path: base64(bytes)}``. Base64 rather than text because a
1640    #: patch target may legitimately be binary, and decoding it into a
1641    #: string would corrupt it into something that merely looks like a
1642    #: bad submission.
1643    files_b64: dict[str, str] = Field(default_factory=dict)
1644    #: ``{path: sha256}`` — an equality match across teams, which is
1645    #: what is left once uniqueness has stopped flag sharing but nothing
1646    #: stops *patch* sharing.
1647    digests: dict[str, str] = Field(default_factory=dict)
1648    #: The node's per-stage results for the latest verification, so a
1649    #: disputed `inconclusive` says *which* stage — the service never
1650    #: came up, the checker could not run, the exploit could not run —
1651    #: rather than one sentence for all three. Empty on a submission
1652    #: judged before this shipped, and on one the platform never judged.
1653    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):
1593class AdminPatchSubmissionInfo(PatchSubmissionInfo):
1594    """One row of the organiser's defence queue.
1595
1596    The player's view plus the four things only an organiser asks:
1597    *whose* it is, and — when a verdict is disputed — which worker
1598    decided it after how many tries.
1599
1600    Still **without the code**, and here that is a stronger requirement
1601    than on the parent: this is a competition-wide listing, so shipping
1602    bodies would mean reading every stored patch of the event to render
1603    a table of statuses. The bytes live on
1604    :class:`AdminPatchSubmissionDetail`, one row at a time.
1605    """
1606
1607    team_id: str = ""
1608    user_id: str = ""
1609    #: Which worker judged it, for correlating with that node's logs.
1610    node_id: str = ""
1611    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):
1656class AdminPatchSubmissionPage(CtfyModel):
1657    """A page of the defence queue, with the total behind it.
1658
1659    ``total`` is its own query rather than ``len(items)`` so a pager can
1660    say "1-20 of 340" — and, with a ``status`` filter, it is also the
1661    answer to "is the queue draining".
1662    """
1663
1664    items: list[AdminPatchSubmissionInfo] = Field(default_factory=list)
1665    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 AdminProbeInfo(ctfy.core.models.CtfyModel):
597class AdminProbeInfo(CtfyModel):
598    """The AI pre-check of one upload, for organisers and reviewers (#3053).
599
600    ``models`` is the event's list as it is now (``[]`` = off), and
601    ``can_rerun`` whether *this caller* may press "pre-check again" now —
602    an organiser of the event, models chosen, and budget left on the version
603    under review. The page offers the button from this field alone.
604    """
605
606    models: list[str] = Field(default_factory=list)
607    whitebox_model: str = ""
608    #: The version cap in force, and what the version under review has spent.
609    version_turns: int = 0
610    version_seconds: int = 0
611    spent_turns: int = 0
612    spent_seconds: int = 0
613    can_rerun: bool = False
614    #: Whether the upload's authors read this tab (the event's switch).
615    authors_read: bool = False
616    live: ProbeVersion | None = None
617    revision: ProbeVersion | None = None

The AI pre-check of one upload, for organisers and reviewers (#3053).

models is the event's list as it is now ([] = off), and can_rerun whether this caller may press "pre-check again" now — an organiser of the event, models chosen, and budget left on the version under review. The page offers the button from this field alone.

models: list[str] = PydanticUndefined
whitebox_model: str = ''
version_turns: int = 0
version_seconds: int = 0
spent_turns: int = 0
spent_seconds: int = 0
can_rerun: bool = False
authors_read: bool = False
live: ProbeVersion | None = None
revision: ProbeVersion | None = None
class AdminProfileResetRequest(ctfy.core.models.CtfyModel):
317class AdminProfileResetRequest(CtfyModel):
318    """Body for ``POST /admin/users/{id}/profile-reset`` (#2775)."""
319
320    field: ProfileField
321    # The ban's floor: the person is mailed it and the audit row keeps it.
322    reason: str = Field(min_length=10, max_length=2000)
323    #: Keep the account from setting the field itself for this many days
324    #: (``0`` = it may change it again at once).
325    lock_days: int = Field(default=0, ge=0, le=MAX_LOCK_DAYS)

Body for POST /admin/users/{id}/profile-reset (#2775).

field: Literal['display_name', 'avatar', 'bio', 'links'] = PydanticUndefined
reason: str = PydanticUndefined
lock_days: int = 0
class AdminProfileResetResult(ctfy.core.models.CtfyModel):
328class AdminProfileResetResult(CtfyModel):
329    """What ``POST /admin/users/{id}/profile-reset`` changed (#2775)."""
330
331    user_id: str
332    field: ProfileField
333    #: When the account may set the field again; ``None`` = at once.
334    locked_until: AwareDatetime | None = None

What POST /admin/users/{id}/profile-reset changed (#2775).

user_id: str = PydanticUndefined
field: Literal['display_name', 'avatar', 'bio', 'links'] = PydanticUndefined
locked_until: pydantic.types.AwareDatetime | None = None
class AdminRecentError(ctfy.core.models.CtfyModel):
224class AdminRecentError(CtfyModel):
225    """An archived instance that ended in ``stop_reason="error"``."""
226
227    instance_id: str
228    challenge_id: str = ""
229    team_id: str = ""
230    node_id: str = ""
231    error: str = ""
232    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 AdminRepriceInfo(ctfy.server.models.RepriceInfo):
1757class AdminRepriceInfo(RepriceInfo):
1758    """The organisers' copy of a re-price: which challenge, who, the entry
1759    before and after, and the standings it replaced (``board_before``, before
1760    any anonymity projection — organisers only)."""
1761
1762    challenge_id: str = ""
1763    repriced_by: str = ""
1764    #: What to call ``repriced_by`` (``operator_label``); ``""`` beside a
1765    #: blank id or an account that is gone.
1766    repriced_by_display_name: str = ""
1767    before: ScoringManifestEntryInfo = Field(default_factory=ScoringManifestEntryInfo)
1768    after: ScoringManifestEntryInfo = Field(default_factory=ScoringManifestEntryInfo)
1769    board_before: list[ScoringChangeBoardRow] = Field(default_factory=list)

The organisers' copy of a re-price: which challenge, who, the entry before and after, and the standings it replaced (board_before, before any anonymity projection — organisers only).

challenge_id: str = ''
repriced_by: str = ''
repriced_by_display_name: str = ''
before: ScoringManifestEntryInfo = PydanticUndefined
after: ScoringManifestEntryInfo = PydanticUndefined
board_before: list[ScoringChangeBoardRow] = PydanticUndefined
class AdminScoringChangeInfo(ctfy.server.models.ScoringChangeInfo):
1718class AdminScoringChangeInfo(ScoringChangeInfo):
1719    """The organisers' copy: who, and the standings the change replaced.
1720
1721    ⚠️ ``board_before`` is the raw board before any anonymity projection —
1722    the one thing a recompute under the new rule cannot reproduce — which
1723    is why only organisers read it.
1724    """
1725
1726    changed_by: str = ""
1727    #: What to call ``changed_by`` (``operator_label``); ``""`` beside a
1728    #: blank id or an account that is gone.
1729    changed_by_display_name: str = ""
1730    board_before: list[ScoringChangeBoardRow] = Field(default_factory=list)

The organisers' copy: who, and the standings the change replaced.

⚠️ board_before is the raw board before any anonymity projection — the one thing a recompute under the new rule cannot reproduce — which is why only organisers read it.

changed_by: str = ''
changed_by_display_name: str = ''
board_before: list[ScoringChangeBoardRow] = PydanticUndefined
class AdminScoringChangesResponse(ctfy.core.models.CtfyModel):
1830class AdminScoringChangesResponse(CtfyModel):
1831    """The organisers' copy of :class:`ScoringChangesResponse`."""
1832
1833    changes: list[AdminScoringChangeInfo] = Field(default_factory=list)

The organisers' copy of ScoringChangesResponse.

changes: list[AdminScoringChangeInfo] = PydanticUndefined
class AdminSignInRemovalRequest(ctfy.core.models.CtfyModel):
294class AdminSignInRemovalRequest(CtfyModel):
295    """Body for an operator unlinking an identity or removing an address (#2777)."""
296
297    # The ban's floor: the person is mailed it and the audit row keeps it.
298    reason: str = Field(min_length=10, max_length=2000)
299    #: Also revoke every session and token (#2777 review): whoever held the
300    #: removed way in may already hold a bearer minted through it.
301    sign_out: bool = True

Body for an operator unlinking an identity or removing an address (#2777).

reason: str = PydanticUndefined
sign_out: bool = True
class AdminSignInRemovalResult(ctfy.core.models.CtfyModel):
304class AdminSignInRemovalResult(CtfyModel):
305    """What an operator's unlink or address removal took away (#2777)."""
306
307    user_id: str
308    action: Literal["identity_unlinked", "email_removed"]
309    #: The provider unlinked, or ``""`` for an address.
310    provider: str = ""
311    #: The address removed, or ``""`` for an identity.
312    email: str = ""
313    #: Sessions and tokens revoked with it (``0`` without ``sign_out``).
314    tokens_revoked: int = 0

What an operator's unlink or address removal took away (#2777).

user_id: str = PydanticUndefined
action: Literal['identity_unlinked', 'email_removed'] = PydanticUndefined
provider: str = ''
email: str = ''
tokens_revoked: int = 0
class AdminSignOutRequest(ctfy.core.models.CtfyModel):
272class AdminSignOutRequest(CtfyModel):
273    """Body for ``POST /admin/users/{id}/sign-out`` (#2773)."""
274
275    # The ban's floor: the person is mailed it and the audit row keeps it.
276    reason: str = Field(min_length=10, max_length=2000)
277    #: Also remove the account's password, so the next sign-in has to
278    #: re-prove a mailbox (an emailed code) or a linked provider.
279    reset_password: bool = False

Body for POST /admin/users/{id}/sign-out (#2773).

reason: str = PydanticUndefined
reset_password: bool = False
class AdminSignOutResult(ctfy.core.models.CtfyModel):
282class AdminSignOutResult(CtfyModel):
283    """What ``POST /admin/users/{id}/sign-out`` ended (#2773)."""
284
285    user_id: str
286    #: Live sessions and API tokens revoked.
287    tokens_revoked: int
288    #: Open event streams and shells closed.
289    streams_closed: int
290    shells_closed: int
291    password_removed: bool

What POST /admin/users/{id}/sign-out ended (#2773).

user_id: str = PydanticUndefined
tokens_revoked: int = PydanticUndefined
streams_closed: int = PydanticUndefined
shells_closed: int = PydanticUndefined
password_removed: bool = PydanticUndefined
class AdminSilentChallenge(ctfy.core.models.CtfyModel):
243class AdminSilentChallenge(CtfyModel):
244    """A challenge that has launches and submissions but no full solves —
245    a strong signal that the challenge or its flag is broken."""
246
247    challenge_id: str
248    name: str = ""
249    launches: int = 0
250    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):
30class AdminSolveCell(CtfyModel):
31    """One cell in the team × challenge solve matrix.
32
33    Unsolved cells are emitted when the team has at least one submission
34    against the challenge (``attempts > 0``) so the UI can distinguish
35    "tried and failed" from "never attempted".
36    """
37
38    team_id: str
39    challenge_id: str
40    solved: bool = False
41    solved_at: datetime | None = None
42    solve_time_s: float = 0.0
43    attempts: int = 0
44    # True when this team is the first (fastest) solver of the challenge.
45    first_blood: bool = False
46    # How many of the challenge's declared questions this team has
47    # captured in-window. ``solved`` is the special case where this
48    # equals the challenge's ``milestones``.
49    #
50    # ⚠️ **A partial capture is the whole point of this field.** Before
51    # it, a team 30 milestones into a 33-milestone range was
52    # indistinguishable from one that guessed wrong three times: the
53    # cell's only progress signal was ``solved``, and a range is
54    # fully solved almost never. Measured on the real route with a team
55    # holding 4 of 5 questions -- ``solved=False, attempts=4``, the same
56    # shape four wrong guesses produce.
57    #
58    # ⚠️ ``0`` here is a true zero (nothing captured), unlike
59    # ``AdminSolveMatrixChallenge.milestones``.
60    captured: int = 0
61    # When this team last took a milestone here.
62    #
63    # ⚠️ **Not a second copy of ``solved_at``**, though on a completed
64    # challenge the two carry the same instant. They answer different
65    # questions -- "when was this finished" (null while it is not) and
66    # "when did this team last make progress" (null only if it never
67    # has) -- and on a 33-milestone range the first is null all event
68    # while the second is the only thing moving. They cannot disagree:
69    # both are derived from the same captured-flag map, and a full
70    # solve's final capture *is* what completed it.
71    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):
103class AdminSolveMatrix(CtfyModel):
104    """Team × challenge solve matrix for the admin dashboard.
105
106    Rows (teams) and columns (challenges) pre-sorted — teams by solve count
107    desc, challenges by category → difficulty — so the frontend can render
108    the grid verbatim without re-sorting. Cells are sparse: only teams that
109    have at least attempted a challenge contribute a row there.
110    """
111
112    teams: list[AdminSolveMatrixTeam] = Field(default_factory=list)
113    challenges: list[AdminSolveMatrixChallenge] = Field(default_factory=list)
114    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):
 82class AdminSolveMatrixChallenge(CtfyModel):
 83    challenge_id: str
 84    name: str = ""
 85    category: str = ""
 86    difficulty: str = ""
 87    solves_count: int = 0
 88    # How many questions the challenge declares -- the denominator for
 89    # every cell's ``captured``.
 90    #
 91    # ⚠️ **On the column, never on the cell.** It is a property of the
 92    # challenge, so putting it on all 6,600 cells would be one number
 93    # copied per square with nothing keeping the copies equal.
 94    #
 95    # ⚠️ ``0`` means **not declared**, never "no milestones" -- a spec
 96    # the reader could not resolve (a challenge retired after a solve)
 97    # lands here. A reader must render no fraction at all rather than
 98    # dividing by it; a `captured / 0` is what puts `NaN%` or `Infinity`
 99    # on a wall in a room full of people.
100    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):
74class AdminSolveMatrixTeam(CtfyModel):
75    team_id: str
76    name: str = ""
77    solved: int = 0
78    #: See :attr:`ctfy.server.models.ScoreboardEntry.anonymous`.
79    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):
212class AdminStuckInstance(CtfyModel):
213    """A live instance stuck in STARTING for too long — likely a node-side
214    failure that didn't reconcile to ERROR cleanly."""
215
216    instance_id: str
217    challenge_id: str = ""
218    team_id: str = ""
219    node_id: str = ""
220    requested_at: float = 0.0
221    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):
1190class AdminTaskInfo(CtfyModel):
1191    """One background task on ``GET /admin/tasks`` / detail. A projection
1192    of ``TaskState`` onto the wire (these routes are admin-only, so the
1193    full ``error_detail`` traceback tail is included)."""
1194
1195    id: str = ""
1196    kind: str = ""
1197    status: str = ""
1198    params: dict[str, Any] = Field(default_factory=dict)
1199    # ⚠️ Typed here and **not** on ``TaskState``. That one is a state
1200    # model feeding ``to_orm`` over a freeform JSON column whose old
1201    # rows predate every current handler; this one exists to reach a
1202    # client, which is where the type is worth anything — it is what
1203    # ``openapi-typescript`` carries into the browser, replacing a
1204    # cast and seven hand-written coercions in ``TaskResultDetails``.
1205    result: TaskResult = Field(default_factory=TaskResult)
1206    progress: float = 0.0
1207    progress_message: str = ""
1208    # Which step of *this handler's own plan* the run is inside, or ``""``
1209    # for a handler with no plan — which is every one but the three the
1210    # range studio dispatches.
1211    #
1212    # ⚠️ **`str`, and deliberately not `VerificationStepKey`**, even
1213    # though that is the only producer today. The domain is per-handler:
1214    # a second planned handler brings its own key space, so narrowing
1215    # here would publish a promise this field cannot keep, and adding
1216    # ``""`` to the union would be a member meaning *unset* — the reading
1217    # `AdminTimeseries.metric` refuses. The completeness that matters is
1218    # already typed where it belongs, on ``GauntletBuildStep.key``; a
1219    # reader **looks this value up in the plan** rather than indexing a
1220    # palette with it, so an unrecognised step marks no row live instead
1221    # of throwing.
1222    progress_step: str = ""
1223    error: str = ""
1224    error_detail: str = ""
1225    cancel_requested: bool = False
1226    created_by_user_id: str = ""
1227    created_by_name: str = ""
1228    created_at_ts: float = 0.0
1229    started_at_ts: float = 0.0
1230    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):
1233class AdminTaskListResponse(CtfyModel):
1234    """Paginated task list."""
1235
1236    items: list[AdminTaskInfo] = Field(default_factory=list)
1237    total: int = 0
1238    offset: int = 0
1239    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):
1242class AdminTaskLogLine(CtfyModel):
1243    """One verbose log line for a task (admin-only)."""
1244
1245    seq: int = 0
1246    ts: float = 0.0
1247    level: str = "info"
1248    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):
1251class AdminTaskLogsResponse(CtfyModel):
1252    """A cursor page of task logs. ``next_after`` is the ``seq`` to pass as
1253    ``?after=`` on the next poll (the last item's seq, or the request's
1254    ``after`` when the page is empty — so an empty poll never rewinds the
1255    cursor)."""
1256
1257    items: list[AdminTaskLogLine] = Field(default_factory=list)
1258    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):
1261class AdminTaskSubmitRequest(CtfyModel):
1262    """Body of ``POST /admin/tasks``. ``kind`` is validated against the
1263    runner's handler registry; ``params`` is the kind-specific input
1264    (e.g. ``{"competition_id": ...}`` or ``{"job": "ttl_sweep"}``)."""
1265
1266    kind: str = ""
1267    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):
812class AdminTeamRow(CtfyModel):
813    """One row of the organiser's team table.
814
815    Deliberately **not** ``TeamInfo`` plus a field. ``TeamInfo`` is the
816    public projection, and standing is not public: a disqualification
817    reaches the team and the organisers, and announcing it more widely is
818    the organiser's call — the same reasoning that keeps
819    ``team_disqualified`` off the public SSE feed. Putting
820    ``disqualified`` on the public model would publish the verdict on
821    every team page, from a route nobody had to think about.
822
823    Also **not** ``RegistrationTeamRow``: that one carries every member's
824    sealed personal details and lands an audit row for the bulk read.
825    Managing a roster does not need anybody's phone number, and paying
826    that price to render a table would make the audit trail meaningless
827    by filling it with routine work.
828    """
829
830    id: str
831    name: str
832    description: str = ""
833    competition_id: str = ""
834    captain_user_id: str = ""
835    captain_display_name: str = ""
836    member_count: int = 0
837    created_at: datetime | None = None
838    review_status: str = ""
839    disqualified: bool = False
840    disqualified_at: datetime | None = None
841    #: Who took this team out, as a name rather than an id.
842    #:
843    #: ⚠️ **Resolved server-side**, the repair ``AdminHarnessRow``
844    #: records: a client mapping ids to names needs a read per row and
845    #: eventually disagrees with the server about a deleted account.
846    #:
847    #: ⚠️ **Two different blanks, and neither is the platform.** A row
848    #: disqualified before the column existed names nobody; an id
849    #: nothing resolves is an account that has gone. Both read as
850    #: *unattributed* here — the activity row keeps the id for whoever
851    #: needs it — because inventing an author is worse than an empty
852    #: cell, and ``actor_id=""`` already means an unattributable actor
853    #: everywhere else in this codebase.
854    disqualified_by_display_name: str = ""
855    disqualification_reason: str = ""
856    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):
337class AdminTimeseries(CtfyModel):
338    """24h-by-default bucketed counts driving the admin Overview pulse charts."""
339
340    #: ⚠️ **Required, with no default.** Every other string here
341    #: defaults to ``""``, and adding ``""`` to the union to keep that
342    #: habit would hand the client a seventh member meaning *unset* —
343    #: for a field the one producer always writes from a validated
344    #: query parameter. A default would be a new claim; a required
345    #: field makes a producer that forgets fail loudly instead.
346    metric: AdminTimeseriesMetric
347    window_s: int = 0
348    bucket_s: int = 0
349    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):
285class AdminTimeseriesBucket(CtfyModel):
286    """One bucket of an admin time-series chart.
287
288    ``ts`` is the right edge of the bucket in Unix seconds — the natural
289    point to evaluate "active right now" metrics like running_instances —
290    and the natural label to render at the right of the bucket on the
291    frontend.
292
293    ``partial=True`` marks the rightmost bucket whose right edge is
294    ``now``; it represents an in-progress window that hasn't finished
295    accumulating yet.
296
297    ⚠️ **This sentence used to describe a renderer that did not exist**
298    ("semi-transparent fill, pulsing label"), and could not: the
299    hand-written copy of this model in ``frontend/src/api.ts`` had
300    dropped the field, so nothing could read it and ``tsc`` could not
301    report the attempt. What the charts do now is plot the **finished**
302    windows and say *live* beside the headline while one is filling —
303    an in-progress count drawn at the weight of a whole window is a dip
304    at the right edge of every chart that is only the clock.
305    """
306
307    ts: float = 0.0
308    value: int = 0
309    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):
664class AdminTokenRevokeResponse(CtfyModel):
665    """Result of ``POST /admin/tokens/{token_id}/revoke``.
666
667    The revoke half of the trace surface: ``token trace`` resolves a
668    leaked ``pf_…`` to its ``token_id`` (marking the *live* one in a
669    rotation lineage), and this ends that row. Owner-revoke is
670    account-scoped by design, so an operator holding a leaked secret —
671    especially one minting synthetic **agent** entrants, whose owner is
672    a user nobody can log in as — had no way to end it short of a direct
673    DB write. This is that door.
674
675    ⚠️ **A miss here is a 404, unlike ``token-trace``'s 200.** The trace
676    route takes a *secret* and "not one we minted" is the reassuring
677    answer; this route takes a *``token_id``* the operator already read
678    off a trace, so an id that resolves to nothing is a wrong id, and a
679    404 says exactly that.
680
681    ⚠️ **Revocation is idempotent, not an error.** Re-revoking a row
682    that is already revoked returns ``already_revoked=True`` at 200 with
683    its original ``revoked_at`` — the compare-and-set in the backend is
684    what makes a second caller safe, and the audit event fires only on
685    the transition.
686    """
687
688    token_id: str
689    owner_user_id: str = ""
690    owner_display_name: str = ""
691    kind: TokenKind
692    label: str = ""
693    already_revoked: bool = False
694    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):
506class AdminTrafficInstanceRow(CtfyModel):
507    """One instance × team × challenge row for the admin traffic dashboard."""
508
509    instance_id: str
510    team_id: str
511    team_name: str = ""
512    challenge_id: str = ""
513    name: str = ""
514    status: str = ""
515    request_count: int = 0
516    started_at: float = 0.0
517    stopped_at: float = 0.0
518    # ``True`` if the instance is still running (request_count is approximate
519    # — refreshed only when the dashboard re-fetches).
520    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):
523class AdminTrafficSummary(CtfyModel):
524    """Top-level response for ``GET /admin/traffic/summary``.
525
526    ``teams`` and ``total_requests`` are sums over **every** archived
527    instance; ``instances`` lists only the newest archived ones (plus the
528    live boxes), because a row per instance ever run is a payload that
529    grows without bound.
530
531    ⚠️ **All three used to be computed from that same newest page**, under
532    a module docstring saying "every archived instance": a team whose
533    traffic was older had none, and ``total_requests`` stopped growing at
534    the cap. The totals are summed in SQL now, which is what makes the
535    list a *page* of something larger — so the response says how large.
536    """
537
538    teams: list[AdminTrafficTeamRow] = Field(default_factory=list)
539    instances: list[AdminTrafficInstanceRow] = Field(default_factory=list)
540    total_requests: int = 0
541    #: Every archived instance with a team plus every live one — what
542    #: ``instances`` is the newest part of, and what the page's instance
543    #: count shows. ``len(instances)`` is not a count of anything.
544    #:
545    #: ⚠️ Defaults ``0`` so the field set stays additive, as
546    #: ``AdminOverview``'s counters do; a server that predates the field
547    #: sends none, so a reader released separately from the server (the
548    #: CLI) takes ``max(instances_total, len(instances))`` rather than
549    #: reporting zero beside a list of rows.
550    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):
491class AdminTrafficTeamRow(CtfyModel):
492    """Per-team aggregate row for the admin traffic dashboard.
493
494    Sums HTTP request counts across every archived instance the team has
495    ever launched, plus the live count for any instances the team is
496    still running.
497    """
498
499    team_id: str
500    team_name: str = ""
501    instance_count: int = 0
502    request_count: int = 0
503    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):
235class AdminUnhealthyNode(CtfyModel):
236    node_id: str
237    display_name: str = ""
238    url: str = ""
239    last_heartbeat_ts: float = 0.0
240    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):
377class AdminUserInfo(UserInfo):
378    """Admin-only view of a user. Carries the privilege tier and the
379    most recent role-change audit fields."""
380
381    email: str = ""
382    role: UserRole = "user"
383    promoted_by: str | None = None
384    promoted_at: datetime | None = None
385    #: What to call the account in ``promoted_by``. Resolved in bulk at
386    #: the route by `ctfy.server.entity_labels.operator_labels` — the
387    #: operator's answer, so it ends at the address when there is no
388    #: display name, because the reader is asking *who granted this*
389    #: and an address is how they go and ask about it.
390    #:
391    #: ⚠️ **Empty is never the id**, the contract
392    #: `InstanceInfo.team_name` states: the id is on the wire beside
393    #: this, so echoing it would make "the account is gone" render
394    #: identically to a resolved row and leave the client with no way
395    #: to tell that it should fall back.
396    promoted_by_display_name: str = ""
397    #: When a verification code was last mailed to this account, and how
398    #: that attempt ended (``accepted`` / ``provider_error`` /
399    #: ``not_configured``). ``None`` / ``""`` = never attempted -- which,
400    #: on an unproved account, is the fact an operator is looking for when
401    #: a user says no code arrived. ⚠️ ``accepted`` is the provider taking
402    #: the message, not the mailbox receiving it.
403    verification_code_sent_at: datetime | None = None
404    verification_code_outcome: VerificationSendOutcome | Literal[""] = ""
405    # OAuth providers the user has bound (e.g. ``["github", "google"]``),
406    # so the admin user list can surface which SSO accounts are linked.
407    providers: list[str] = Field(default_factory=list)
408    #: Each bound identity in detail (#2772): the per-provider badges.
409    sso: list[SsoBindingInfo] = Field(default_factory=list)
410    #: Every way this account can sign in, in the statistics' fixed order
411    #: (:func:`ctfy.core.user_stats.login_methods_of`) -- the 登录方式 column.
412    login_methods: list[LoginMethod] = Field(default_factory=list)
413    #: The proved school, or ``None`` -- the 学校 column.
414    school: UserSchoolInfo | None = None
415    #: How many administrator notes the account has (#2776) -- the list's
416    #: note marker. Admin-only, like this whole projection.
417    admin_notes: int = 0
418    #: When this account was taken off public standings; ``None`` = it
419    #: counts. Here so the admin list can show which accounts are the
420    #: deployment's own without a second read.
421    standings_excluded_at: datetime | None = None
422    #: The account's platform ban (#2068), in force or not -- ``None`` when it
423    #: has never been banned. Read ``active``, never the row's existence.
424    account_ban: AccountBanInfo | None = None
425    #: The restrictions in force now (#2774), short of a ban; empty = none.
426    restrictions: list[AccountRestrictionInfo] = Field(default_factory=list)
427    #: When this account's self-requested deletion becomes final;
428    #: ``None`` = it never asked. `DELETE /me` stamps it and revokes
429    #: every token, so a pending account is *locked* — which on this
430    #: page is otherwise indistinguishable from one that simply stopped
431    #: playing. Here for ``standings_excluded_at``'s reason: the admin
432    #: list can show it without a second read.
433    #:
434    #: ⚠️ **The instant, never a ``pending: bool``.** An operator
435    #: arrives with two questions — *is this pending* and *when does it
436    #: go* — and the instant answers both where a flag answers one; a
437    #: flag beside it would be two claims about one fact, free to
438    #: disagree.
439    deletion_effective_at: datetime | None = None
440    #: Whether this account has anything outstanding to prove — composed
441    #: server-side from :func:`~ctfy.server.email_verification.user_is_verified`,
442    #: the predicate the write gate itself reads.
443    #:
444    #: ⚠️ **Not ``email_verified_at is not None``**, and the difference is
445    #: a whole population rather than an edge case: an account with no
446    #: address is *outside* the question, not unproved, and three kinds
447    #: of them sit in this very table (every synthetic agent entrant, the
448    #: platform's own service account, and a human on a generic-OIDC
449    #: deployment configured with ``oidc_trust_email_verified=false``).
450    #: The raw column would badge all of them as blocked while the server
451    #: lets every one of their writes through.
452    #:
453    #: ⚠️ Defaults **False**, the loud direction: this model has two
454    #: producers, and a producer that forgets makes every row on the page
455    #: claim it is blocked — visible in a minute — rather than reporting
456    #: a frozen account as fine. The *client* reads ``=== false``, so an
457    #: older server that sends neither field badges nobody.
458    email_verified: bool = False
459    #: When proof of the address first existed, or ``None``.
460    #:
461    #: ⚠️ **Beside the flag, not instead of it**, because ``None`` is two
462    #: different facts and only the pair separates them: with
463    #: ``email_verified`` false it means *never proved* (the actionable
464    #: state), and with it true it means *there is no address to prove*.
465    #: A reader with one field can render two states out of three.
466    email_verified_at: datetime | None = None
467    #: The gate is satisfied by a proved address that is **not** the primary
468    #: (#2763): ``email_verified`` true, the primary still unproved. Without
469    #: it the pair above reads *no address* for an account that has one.
470    email_verified_elsewhere: bool = False
471    #: Who vouched for the address, when the account did not prove it
472    #: itself — the id of the super-admin who used
473    #: ``POST /admin/users/{user_id}/verify-email``. ``""`` is the
474    #: account's own proof.
475    #:
476    #: ⚠️ **Read with the two fields above, never alone.** ``""`` means
477    #: *proved by the account* only where ``email_verified`` is true and
478    #: ``email_verified_at`` is set; on an unproved account it says
479    #: nothing. A reader that branches on this one column answers "the
480    #: account proved it" about an account that has proved nothing.
481    #:
482    #: ⚠️ Published so the table can say *vouched* rather than
483    #: *verified*. Without it an operator's assertion renders
484    #: identically to a mailed code, which is the whole objection the
485    #: column exists to answer — a distinction stored and then withheld
486    #: from the one page that would show it is a distinction nobody has.
487    email_verified_by: str = ""
488    #: What to call the account in ``email_verified_by``. Same resolver
489    #: and the same three states as ``promoted_by_display_name``.
490    #:
491    #: ⚠️ The pair is what makes the column readable, not the name
492    #: alone: a blank here **beside a set** ``email_verified_by`` is an
493    #: operator whose account has since gone, which is a different
494    #: sentence from *nobody vouched* (a blank ``email_verified_by``)
495    #: and the one an audit is actually asking about.
496    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 = ''
verification_code_sent_at: datetime.datetime | None = None
verification_code_outcome: Union[Literal['accepted', 'provider_error', 'not_configured'], Literal['']] = ''
providers: list[str] = PydanticUndefined
sso: list[SsoBindingInfo] = PydanticUndefined
login_methods: list[typing.Literal['password', 'github', 'email_code', 'carsi', 'google', 'oidc']] = PydanticUndefined
school: UserSchoolInfo | None = None
admin_notes: int = 0
standings_excluded_at: datetime.datetime | None = None
account_ban: AccountBanInfo | None = None
restrictions: list[AccountRestrictionInfo] = PydanticUndefined
deletion_effective_at: datetime.datetime | None = None
email_verified: bool = False
email_verified_at: datetime.datetime | None = None
email_verified_elsewhere: bool = False
email_verified_by: str = ''
email_verified_by_display_name: str = ''
class AffiliationInfo(ctfy.core.models.CtfyModel):
156class AffiliationInfo(CtfyModel):
157    """The institution one account publishes, as a stranger reads it.
158
159    Three fields, and what is **absent** is most of the point.
160
161    ⚠️ **The address never travels.** ``users.affiliation_email`` names
162    which of the account's proved addresses the badge derives from and
163    that choice is the person's own business; a ``.edu`` address is
164    often a student number, and the original ask — show the address
165    beside the logo — is a different and much larger disclosure than the
166    one that was agreed. `test_the_badge_publishes_no_address` is the
167    pin, because a field added to this model later would leak it in
168    silence and every surface would go on rendering correctly.
169
170    ⚠️ **No ``verified_at``, and the reason is that the column does not
171    mean what a date on a badge would say.** ``user_emails.verified_at``
172    fills and never overwrites (`rank_solve`'s rule), so it is when
173    proof *first* existed rather than when it was last checked —
174    publishing it as *"verified September 2019"* invites a reader to
175    conclude *stale*, which is a claim this platform cannot make: what
176    it knows is that the address is attached **now** and was proved
177    once, and the badge's own sentence already carries the first half.
178    A field that supports a wrong reading is worse than an absent one.
179
180    ⚠️ **No ``kind`` and no ``url``.** Both are on the catalogue row and
181    neither has a reader here: the chip draws a logo keyed on ``domain``
182    or a monogram from ``short_name``, and its title names the
183    institution. A published field with no reader is the recorded shape
184    (`EvalRunState.transcript_path`), so they arrive with whatever
185    renders them.
186
187    ⚠️ **``names`` crosses as a map**, resolved in the browser, for
188    `InstitutionInfo`'s reason — the language is a client-side
189    preference switched without a reload.
190    """
191
192    #: The catalogue key. The **institution's** domain, never the
193    #: account's address: it is what a logo file is named after
194    #: (``frontend/public/institutions/<domain>.svg``) and it identifies
195    #: a public organisation rather than a person.
196    domain: str = ""
197    names: dict[str, str] = Field(default_factory=dict)
198    #: What a monogram chip draws when the repository ships no logo.
199    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):
202class AffiliationSelection(CtfyModel):
203    """The account's own view of its badge: the choice and its effect.
204
205    ⚠️ **Both halves, because either alone is ambiguous.** ``email``
206    carries the address chosen and ``affiliation`` what strangers
207    actually see, and the interesting state is the one where they
208    disagree: an address that was picked and has since lost its proof,
209    or sits at a domain the operator has not named, reads as *chosen,
210    showing nothing* — which a card holding only ``affiliation`` cannot
211    tell from *nothing chosen*, and the two have opposite repairs
212    (prove it / ask the operator to name the domain, against pick one).
213
214    ⚠️ **The address is here and never on :class:`AffiliationInfo`.**
215    This model answers to the account about itself; that one is what a
216    stranger reads.
217    """
218
219    #: The chosen address, verbatim as the account typed it. ``""`` is
220    #: *no badge*, which is the default and the opt-out.
221    email: str = ""
222    #: What the badge resolves to right now, or ``None``.
223    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):
2559class AgentActionRow(CtfyModel):
2560    """One published entrant's current action, as a room reads it.
2561
2562    ⚠️ **One line, scrubbed and cut, never the conversation.**
2563    ``ctfy.core.agent_action`` picks the newest tool call (or, failing
2564    that, the newest thing the model said), runs it through
2565    ``ctfy.core.redaction`` and caps it at ``MAX_ACTION_CHARS``. The
2566    three rules ``AgentPulseBeat`` states still hold — what changed is
2567    that two of them are answered by the scrub and the third by the
2568    author's own ``TeamState.wall_published_at``.
2569
2570    ⚠️ **An author who never opted in has no row here at all**, rather
2571    than a row with a blank line: a blank is what a published entrant
2572    with nothing to show looks like, and a wall that rendered both the
2573    same would be publishing the *existence* of a decision nobody made.
2574    """
2575
2576    team_id: str = ""
2577    #: ⚠️ **Projected**, like every other public surface naming a squad;
2578    #: ``""`` beside a set id is ``InstanceInfo.team_name``'s third
2579    #: state and never the id as a fallback.
2580    team_name: str = ""
2581    anonymous: bool = False
2582    #: Which model wrote this line. ⚠️ Cleared for a withheld entrant
2583    #: for ``anonymise_awd_board``'s reason — the attribution is
2584    #: identity, so a row that hid the name and kept it would be
2585    #: decorative.
2586    model: str = ""
2587    #: ⚠️ **Required, no default.** ``none`` is a real answer (the turn
2588    #: produced nothing a room can read), so a fourth value meaning
2589    #: *this server did not say* would be a state with no reader —
2590    #: ``AgentPulseBeat.trouble``'s rule.
2591    kind: AgentActionKind
2592    #: The scrubbed line, already cut. ⚠️ Plain text on the wire and
2593    #: plain text on the wall: ``LlmCallBody``'s rule for prose an
2594    #: untrusted image wrote, and a projector is the worst place to
2595    #: relax it.
2596    line: str = ""
2597    #: When the call this was read from **finished** —
2598    #: ``AgentPulseBeat.at_ts``'s floor, repeated because it is the
2599    #: only instant there is.
2600    at_ts: float = 0.0
2601    #: Was there a transcript to read at all? ⚠️ **Separate from an
2602    #: empty ``line``**: retention reclaims a body and leaves the row,
2603    #: so *the agent said nothing* and *the transcript is gone* are
2604    #: opposite facts that render identically at three metres, and this
2605    #: is the only field that can tell them apart.
2606    available: bool = False
2607    #: Whether this entrant's **full conversation** is publicly linkable
2608    #: (B2): the author gave the second consent and the entrant is not
2609    #: anonymous. ⚠️ Not the same as being on the wall — an author may
2610    #: put a line here and withhold the transcript, so the landing feed
2611    #: shows the click-through only when this is set, and a "not
2612    #: published" note otherwise. Defaults ``False``: an older server, or
2613    #: an author who only opted into the line, offers no transcript.
2614    transcript_public: bool = False
2615    #: The call whose line this row shows, so the feed can link straight
2616    #: to its full conversation. ⚠️ **Set only when ``transcript_public``**
2617    #: — the row carries a linkable id exactly when the feed renders a
2618    #: link, and ``""`` for a withheld, anonymous or un-opted-in entrant,
2619    #: so ``anonymise_agent_actions`` has nothing extra to blank. The
2620    #: transcript route re-checks every gate, so a leaked id still meets
2621    #: ``transcript_not_published`` — this only spares the honest reader a
2622    #: dead click.
2623    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):
2626class AgentActionsPage(CtfyModel):
2627    """What this event's published agents are doing.
2628
2629    ⚠️ **Four empty states, and each field exists to separate two of
2630    them.** An empty ``rows`` means one of: this deployment keeps no
2631    transcript index (``indexing``), nobody has entered an agent
2632    (``entrants``), nobody's author opted in (``published``), or
2633    everybody opted in and no call has finished yet (all three set,
2634    ``rows`` empty). Collapsing any pair sends the room — or the
2635    organiser they ask — to look at the wrong thing.
2636    """
2637
2638    competition_id: str = ""
2639    #: ``LlmCallPage.indexing``'s reason: with no gateway there is no
2640    #: index at all, so an empty page means the opposite of "nobody ran
2641    #: an agent". Rendered before the rows by the one reader there is.
2642    indexing: bool = True
2643    #: Agent entrants in this event, opted in or not. ⚠️ The
2644    #: denominator, so ``published`` is readable as a *share* rather
2645    #: than as a bare count — and so "nobody entered" cannot be
2646    #: mistaken for "nobody agreed".
2647    entrants: int = 0
2648    #: How many of those authors said yes. ⚠️ Published even when it
2649    #: equals ``entrants``: a room that cannot tell a full opt-in from
2650    #: a coincidence is being told something the page does not know.
2651    published: int = 0
2652    rows: list[AgentActionRow] = Field(default_factory=list)
2653    #: ⚠️ A route that reads at most ``agent_actions.MAX_ROWS``
2654    #: entrants owes the number it could have drawn, or a complete
2655    #: answer and a truncated one read identically — the rule
2656    #: ``test_a_list_route_says_how_many`` pins one surface along.
2657    #: This is ``published``'s ceiling, not the page's.
2658    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):
1161class AgentEntrantInfo(CtfyModel):
1162    """Whose agent an entry is, for a reader of the leaderboard.
1163
1164    ``entrant_kind`` says a row is not a person; this says *which* agent
1165    and *whose*. Two rows both reading "agent" with nothing behind them
1166    is a ranking nobody can check — and this is the surface where the
1167    checking happens, because a model result nobody can attribute or
1168    reproduce is a claim rather than a measurement.
1169
1170    ⚠️ **Absent for the platform's own eval entrant, not just for
1171    people.** Those harness rows have no owner, so there is no author to
1172    link to and no repository to open; offering one would point a reader
1173    at nobody. The board still marks the row as an agent — the two facts
1174    are separate.
1175
1176    ⚠️ **The image reference is deliberately not here.** It names a
1177    registry path somebody chose privately; ``source_url`` is what its
1178    author opted into publishing, and the digest is what makes a run
1179    reproducible without disclosing where the bytes live.
1180    """
1181
1182    # ⚠️ ``id`` for the entity's own id, ``owner_user_id`` for the
1183    # foreign key — the wire-naming rule, and the frontend reads both
1184    # verbatim (one as a label, one as a link target).
1185    id: str
1186    display_name: str = ""
1187    # What the author called this build. A tag, so a mutable pointer.
1188    version: str = ""
1189    owner_user_id: str = ""
1190    owner_display_name: str = ""
1191    # ⚠️ ``""`` means *not published*, never "closed source": the board
1192    # renders no link and makes no claim about the agent.
1193    source_url: str = ""
1194    # ⚠️ **What actually ran**, as opposed to what it was called. An
1195    # operator approves a digest and the node pulls that digest, so on a
1196    # leaderboard this is the field that makes a result reproducible —
1197    # ``version`` is only what its author typed.
1198    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 AgentEvalBest(ctfy.core.models.CtfyModel):
 85class AgentEvalBest(CtfyModel):
 86    """An agent's best standing on a **public** suite's board (#2928).
 87
 88    The board's own ``harness × model`` row (``PairStanding``) as a stranger
 89    reads it, never a figure computed here: ``rank`` is that row's rank and
 90    ``entrants`` the number of rows on the same board. Best means the
 91    highest mean solve rate (#2614) across every public suite the agent stands on,
 92    then the better rank. A private, hidden or draft suite never counts.
 93    """
 94
 95    competition_id: str
 96    competition_title: str
 97    competition_title_i18n: LocalizedText = Field(default_factory=dict)
 98    #: The model this row ran; blank only when the board names none.
 99    model_display_name: str = ""
100    rank: int
101    entrants: int
102    #: #2614: the row's mean over campaigns, the board's own basis.
103    challenges_solved: float = 0.0
104    challenges_attempted: float = 0.0
105    solve_rate: float = 0.0
106    campaigns: int = 0
107    runs: int = 0
108    last_run_at: datetime | None = None

An agent's best standing on a public suite's board (#2928).

The board's own harness × model row (PairStanding) as a stranger reads it, never a figure computed here: rank is that row's rank and entrants the number of rows on the same board. Best means the highest mean solve rate (#2614) across every public suite the agent stands on, then the better rank. A private, hidden or draft suite never counts.

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_title_i18n: dict[str, str] = PydanticUndefined
model_display_name: str = ''
rank: int = PydanticUndefined
entrants: int = PydanticUndefined
challenges_solved: float = 0.0
challenges_attempted: float = 0.0
solve_rate: float = 0.0
campaigns: int = 0
runs: int = 0
last_run_at: datetime.datetime | None = None
class AgentLogPage(ctfy.core.models.CtfyModel):
1727class AgentLogPage(CtfyModel):
1728    """``GET /eval/runs/{run_id}/agent-log``: the run's console after a cursor.
1729
1730    ⚠️ **Scrubbed before it was held**: the run's own credentials, every
1731    answer the platform holds for its challenge, every credential-shaped
1732    string and every ``FLAG{…}`` shape. What remains is the agent's own text,
1733    rendered by the page as plain text, never markup.
1734    """
1735
1736    run_id: str
1737    state: AgentLogState
1738    reason: AgentLogUnavailable = ""
1739    #: Oldest first. A line's ``seq`` is the cursor; a jump in it is a gap.
1740    lines: list[AgentLogLine] = Field(default_factory=list)
1741    #: The oldest line still held, ``0`` when none is. Lines numbered below
1742    #: it were printed and are no longer kept.
1743    first_seq: int = 0
1744    #: The newest line numbered so far. Ask again with ``after=last_seq``.
1745    last_seq: int = 0
1746    #: More lines after the returned ones are already held.
1747    has_more: bool = False
1748    #: Output with no newline yet (never numbered, may still grow).
1749    partial_stdout: str = ""
1750    partial_stderr: str = ""

GET /eval/runs/{run_id}/agent-log: the run's console after a cursor.

⚠️ Scrubbed before it was held: the run's own credentials, every answer the platform holds for its challenge, every credential-shaped string and every FLAG{…} shape. What remains is the agent's own text, rendered by the page as plain text, never markup.

run_id: str = PydanticUndefined
state: Literal['waiting', 'live', 'ended', 'unavailable'] = PydanticUndefined
reason: Literal['', 'node_unsupported', 'not_kept'] = ''
lines: list[ctfy.core.agent_log.AgentLogLine] = PydanticUndefined
first_seq: int = 0
last_seq: int = 0
has_more: bool = False
partial_stdout: str = ''
partial_stderr: str = ''
class AgentPulseBeat(ctfy.core.models.CtfyModel):
2447class AgentPulseBeat(CtfyModel):
2448    """One **finished** model call, as a projector reads it.
2449
2450    ⚠️ **No content, and that is three independent rules rather than a
2451    preference.** `LlmCallBody` is attacker-authored text; a relayed
2452    conversation carries the challenge's minted answer, which on an AWD
2453    gamebox is the current round's flag; and the body belongs to its
2454    author rather than to their squad, while a wall belongs to the room.
2455    So the metadata *is* the content here.
2456
2457    ⚠️ **No money and no key id.** `LlmCallRow` carries both for an
2458    operator; a public page showing what an entrant's agent costs
2459    discloses the operator's rates and the entrant's budget, and neither
2460    is what a room came to see.
2461    """
2462
2463    #: When the call **finished**. ⚠️ There is no other instant to give:
2464    #: the gateway composes a record once, at completion, so no cadence
2465    #: anywhere can place a conversation in flight — `run_llm_call_drain`
2466    #: states that floor and the surfaces say it out loud rather than
2467    #: implying otherwise by updating quickly.
2468    at_ts: float = 0.0
2469    #: What the model wrote, which is the beat's weight on the wall.
2470    output_tokens: int = 0
2471    #: ⚠️ **Rides along so a wall can tell "0 tokens" from "nobody
2472    #: knows"** — ``extract_usage`` preserves that distinction on purpose
2473    #: and a chart drawing both as a flat tick destroys it at the last
2474    #: metre. ``LlmCallRow.usage_known``'s reason, one surface along.
2475    usage_known: bool = False
2476    upstream_ms: int = 0
2477    #: ⚠️ **Required, no default.** The members are different things to
2478    #: go and look at, so a sixth value meaning *this server did not
2479    #: say* would be a state with no reader and no remedy —
2480    #: ``QuestionCoverageRow.state``'s rule. One producer writes this and
2481    #: it always knows.
2482    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):
2485class AgentPulseLane(CtfyModel):
2486    """One entrant's stream of beats, plus whether they are still going.
2487
2488    ⚠️ **The lane exists for a team with a running agent and no beats
2489    yet**, which is why this is a lane list rather than a flat beat list
2490    a client groups. An agent that has started and not yet finished a
2491    call is a real state and the one a room asks about first; derived
2492    from the beats alone it is indistinguishable from an entrant who
2493    never entered.
2494    """
2495
2496    team_id: str = ""
2497    #: ⚠️ **Projected**, like every other public surface naming a squad.
2498    #: ``""`` beside a set id is the third state ``InstanceInfo.team_name``
2499    #: records — never the id as a fallback.
2500    team_name: str = ""
2501    anonymous: bool = False
2502    #: Which model this entrant's agent is driving. ⚠️ Cleared for a
2503    #: withheld entrant for ``anonymise_awd_board``'s reason: the
2504    #: attribution is identity, so a lane that hid the name and kept it
2505    #: would be decorative.
2506    model: str = ""
2507    #: Is one of this entrant's runs still going? ⚠️ **The whole reason a
2508    #: quiet lane is readable.** Without it, "the agent is thinking",
2509    #: "the agent is finished" and "the drain is broken" are the same gap
2510    #: on the right-hand edge, and they are three different things to do.
2511    working: bool = False
2512    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):
2515class AgentPulsePage(CtfyModel):
2516    """What a competition's agents have been asking a model.
2517
2518    ⚠️ **``indexing`` rides along for ``LlmCallPage``'s reason**: an empty
2519    page means *either* nothing was relayed *or* this deployment keeps no
2520    index, and those call for opposite actions. Rendered before the lanes
2521    by the one reader there is.
2522    """
2523
2524    competition_id: str = ""
2525    indexing: bool = True
2526    lanes: list[AgentPulseLane] = Field(default_factory=list)
2527    #: Every matching call, against the windowed beats above — a route
2528    #: that takes a ``limit`` owes one, or a complete answer and a
2529    #: truncated one read identically.
2530    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):
433class AnswerTraceHit(CtfyModel):
434    """One instance that was minted the traced answer.
435
436    ``live`` distinguishes a still-running instance (matched against the
437    in-memory minted map) from an archived one (matched against the
438    persisted digest). Both are authoritative; the flag only says
439    whether the instance can still be inspected or stopped.
440    """
441
442    instance_id: str = ""
443    question_id: str = ""
444    team_id: str = ""
445    team_name: str = ""
446    challenge_id: str = ""
447    competition_id: str = ""
448    live: bool = False
449    started_at: float = 0.0
450    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):
419class AnswerTraceRequest(CtfyModel):
420    """Body for ``POST /admin/answer-trace``.
421
422    The answer travels in the body rather than a query parameter
423    because a live flag in a URL lands in every access log, proxy
424    cache, and browser history between the operator and the app.
425
426    Accepted in either the ``FLAG{...}`` or bare form — the server
427    normalises both to one digest, matching how submissions grade.
428    """
429
430    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):
493class AnswerTraceResponse(CtfyModel):
494    """Result of ``POST /admin/answer-trace``.
495
496    ``hits`` is normally empty or length 1. More than one means two
497    instances genuinely drew the same value — astronomically unlikely
498    for a 128-bit token, but reported rather than silently collapsed
499    because attributing a leak to the wrong team is worse than
500    reporting ambiguity.
501
502    ``traceable_records`` is the number of archived records that carry
503    a digest at all. Instances archived before answer fingerprinting
504    shipped have none, so an empty ``hits`` on a low count means "this
505    predates tracing", not "this flag was never minted here" — the
506    distinction matters when an organiser is deciding whether to act.
507    """
508
509    answer_hash: str = ""
510    hits: list[AnswerTraceHit] = Field(default_factory=list)
511    searched_live: int = 0
512    searched_records: int = 0
513    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 AppealReplyRequest(ctfy.core.models.CtfyModel):
51class AppealReplyRequest(CtfyModel):
52    """Body for an organiser's reply to an appeal."""
53
54    text: str = Field(min_length=1, max_length=MAX_APPEAL_CHARS * 2)

Body for an organiser's reply to an appeal.

text: str = PydanticUndefined
class AppealRequest(ctfy.core.models.CtfyModel):
41class AppealRequest(CtfyModel):
42    """Body for filing an appeal or its supplement.
43
44    ``max_length`` is characters, not bytes. The server normalises the text
45    (``clean_appeal_text``) and refuses empty or over-long rather than cutting
46    it: a truncated appeal is one nobody wrote."""
47
48    body: str = Field(max_length=MAX_APPEAL_CHARS * 2)

Body for filing an appeal or its supplement.

max_length is characters, not bytes. The server normalises the text (clean_appeal_text) and refuses empty or over-long rather than cutting it: a truncated appeal is one nobody wrote.

body: str = PydanticUndefined
class ArchiveStoreReport(ctfy.core.models.CtfyModel):
355class ArchiveStoreReport(CtfyModel):
356    """The platform's instance-archive store and its last retention pass (#1714).
357
358    Held in process memory by ``run_instance_archive_retention`` and read
359    by ``GET /admin/instance-storage``: absent until the first pass after
360    a restart, which the page shows as *not measured yet*.
361    """
362
363    measured_at: float = 0.0
364    status: PassStatus = PassStatus.OK
365    root: str = ""
366    instances: int = 0
367    total_bytes: int = 0
368    capture_bytes: int = 0
369    metadata_bytes: int = 0
370    #: Held by finished instances — what the two windows can reach.
371    finished_bytes: int = 0
372    #: What the windows in force made due on this pass.
373    due_bytes: int = 0
374    capture_retention_s: int = 0
375    archive_retention_s: int = 0
376    reclaimed_instances: int = 0
377    reclaimed_bytes: int = 0
378    failed: int = 0
379    kept: dict[KeepReason, int] = Field(default_factory=dict)
380    skipped: dict[SkipReason, int] = Field(default_factory=dict)
381    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):
632class AttachmentList(CtfyModel):
633    """Response payload for ``GET /challenges/{id}/attachments``.
634
635    Same data as ``ChallengeInfo.attachments`` but addressable directly
636    so pre-launch UI / agent tooling can fetch it without paging through
637    the catalog. Order matches the on-disk sort.
638    """
639
640    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 AudiencePreview(ctfy.core.models.CtfyModel):
231class AudiencePreview(CtfyModel):
232    """How many threads and readers a send would make."""
233
234    threads: int = 0
235    recipients: int = 0
236    #: Team ids the request named that are not this event's teams.
237    unknown_team_ids: list[str] = Field(default_factory=list)
238    silent: bool = False

How many threads and readers a send would make.

threads: int = 0
recipients: int = 0
unknown_team_ids: list[str] = PydanticUndefined
silent: bool = False
class AudienceRequest(ctfy.core.models.CtfyModel):
222class AudienceRequest(CtfyModel):
223    """Who a send would reach, asked before sending."""
224
225    audience: ComposeAudience
226    user_id: str = ""
227    team_ids: list[str] = Field(default_factory=list, max_length=1000)
228    conditions: list[TeamCondition] = Field(default_factory=list, max_length=8)

Who a send would reach, asked before sending.

audience: Literal['user', 'team', 'teams', 'all_teams'] = PydanticUndefined
user_id: str = ''
team_ids: list[str] = PydanticUndefined
conditions: list[typing.Literal['all', 'report_missing', 'review_not_approved', 'no_solves']] = PydanticUndefined
class AuditFinding(ctfy.core.models.CtfyModel):
1165class AuditFinding(CtfyModel):
1166    """One rule's complaint about an uploaded tree."""
1167
1168    #: `META007`, `STRUCT011`, `EXPLOIT007` — the id an author can look
1169    #: up, which is why it travels rather than being folded into prose.
1170    rule: str
1171    #: `error` or `warning` as the audit spells them. Deliberately not
1172    #: narrowed: `Severity` is the audit's enum and this crosses a
1173    #: subprocess boundary as text, so a member a newer auditor grows
1174    #: must reach the page rather than 500 on the way.
1175    severity: str = ""
1176    #: Relative to the benchmark directory, or empty for a finding about
1177    #: the tree as a whole.
1178    path: str = ""
1179    #: ``None`` rather than ``0``: there is no line 0, so a falsy
1180    #: default would render as a location the finding is not about.
1181    line: int | None = None
1182    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):
144class AuthTokenResponse(CtfyModel):
145    """Returned by ``POST /auth/register`` and ``POST /auth/login``.
146
147    The plaintext user token is returned directly (not via fragment) since
148    these endpoints are called by first-party XHR, not redirects.
149    """
150
151    token: str
152    redirect_to: str = "/"
153    #: What registration did about proving the address: ``sent`` / the
154    #: failure outcomes when a code was attempted, ``not_needed`` when none
155    #: was (login, or a deployment that does not require proof).
156    #: ⚠️ ``not_needed`` is the default so an unset field asserts nothing.
157    verification_code: VerificationCodeOutcome = "not_needed"

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 = '/'
verification_code: Union[Literal['accepted', 'provider_error', 'not_configured'], Literal['not_needed']] = 'not_needed'
class AuthorModelReview(ctfy.core.models.CtfyModel):
437class AuthorModelReview(CtfyModel):
438    """The model's reading of one version of your upload (#3050).
439
440    Shown only where the upload's event lets its authors read it. ``verdict``
441    is what the platform acts on (``clean`` once a person overrode an
442    ``injection``); ``problem`` says why a reading is ``unknown``. The
443    findings are advice about the challenge's quality, never a refusal.
444    """
445
446    #: ``""`` for the upload's own tree; otherwise the revision read.
447    revision_id: str = ""
448    subject_submitted_at: AwareDatetime | None = None
449    verdict: ModelReviewInjection = "unknown"
450    problem: ModelReviewProblem = ""
451    findings: list[ModelReviewFindingItem] = Field(default_factory=list)
452    reviewed_at: AwareDatetime | None = None

The model's reading of one version of your upload (#3050).

Shown only where the upload's event lets its authors read it. verdict is what the platform acts on (clean once a person overrode an injection); problem says why a reading is unknown. The findings are advice about the challenge's quality, never a refusal.

revision_id: str = ''
subject_submitted_at: pydantic.types.AwareDatetime | None = None
verdict: Literal['clean', 'injection', 'unknown'] = 'unknown'
problem: Literal['', 'no_gateway', 'no_dialect', 'unreachable', 'unreadable', 'ungrounded', 'incomplete', 'failed'] = ''
findings: list[ctfy.core.state.models.ModelReviewFindingItem] = PydanticUndefined
reviewed_at: pydantic.types.AwareDatetime | None = None
class AuthorModelReviewInfo(ctfy.core.models.CtfyModel):
455class AuthorModelReviewInfo(CtfyModel):
456    """The model's readings of your upload: the live tree and the proposed
457    revision, each ``None`` while none was taken (#3050)."""
458
459    review: AuthorModelReview | None = None
460    revision_review: AuthorModelReview | None = None

The model's readings of your upload: the live tree and the proposed revision, each None while none was taken (#3050).

review: AuthorModelReview | None = None
revision_review: AuthorModelReview | None = None
class AuthorProbeInfo(ctfy.core.models.CtfyModel):
620class AuthorProbeInfo(CtfyModel):
621    """The AI pre-check of your upload (#3053), where the event shows it to
622    its authors: the same runs, numbers and findings the reviewers read, with
623    no budget and no rerun."""
624
625    live: ProbeVersion | None = None
626    revision: ProbeVersion | None = None

The AI pre-check of your upload (#3053), where the event shows it to its authors: the same runs, numbers and findings the reviewers read, with no budget and no rerun.

live: ProbeVersion | None = None
revision: ProbeVersion | None = None
class AuthorReputation(ctfy.core.models.CtfyModel):
104class AuthorReputation(CtfyModel):
105    """How players received an uploader's published challenges (plan P3.5).
106
107    Informational, and for reviewers and admins only: it orders the review
108    queue when asked and gates nothing — review is the control, never a
109    reward. Read through the curve that prices authoring on a
110    ``peer_authoring`` board, full marks for a challenge most entrants
111    fail and somebody finishes, and averaged over the uploads that have a
112    rate.
113    """
114
115    #: The mean curve value over the uploads with a rate, between the
116    #: curve's floor and 1. ``None`` is *no reading* — nothing published,
117    #: or nothing tried by enough entrants to rate — never a zero.
118    score: float | None = None
119    #: How many published uploads had enough entrants to carry a rate.
120    priced: int = 0
121    #: How many published uploads were read at all.
122    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 AuthorVersionRow(ctfy.server.models.AuthoredVersionRow):
2132class AuthorVersionRow(AuthoredVersionRow):
2133    """`AuthoredVersionRow` on the author's versions list, with the version's
2134    "AI 2/3" (#3093).
2135
2136    ⚠️ **A subclass, never a field on the base**: the base also travels in
2137    the diff answer, which has no reason to carry a pre-check, and the
2138    no-public-reader walk would rightly find it there.
2139    """
2140
2141    #: Its newest AI pre-check, matched by the tree's digest. ``None`` is
2142    #: *none run* — or an event that keeps the pre-check from its authors.
2143    ai_precheck: ProbeBrief | None = None

AuthoredVersionRow on the author's versions list, with the version's "AI 2/3" (#3093).

⚠️ A subclass, never a field on the base: the base also travels in the diff answer, which has no reason to carry a pre-check, and the no-public-reader walk would rightly find it there.

ai_precheck: ProbeBrief | None = None
class AuthoredActionEffectInfo(ctfy.core.models.CtfyModel):
1686class AuthoredActionEffectInfo(CtfyModel):
1687    """One thing pressing an action does besides its own sentence (#2740 §4),
1688    listed only when it is true now. ``count`` is the number it names
1689    (running environments, competitions), ``0`` when it names none."""
1690
1691    code: AuthoredActionEffect
1692    count: int = 0
1693    #: The second number, for a sentence that names two (``scores_kept`` /
1694    #: ``scores_removed``: the teams holding the solves, #2991).
1695    teams: int = 0

One thing pressing an action does besides its own sentence (#2740 §4), listed only when it is true now. count is the number it names (running environments, competitions), 0 when it names none.

code: Literal['checked_again', 'players_keep_current', 'author_may_revise', 'nobody_new', 'players_after_start', 'competitions_listing', 'not_open_to_everyone', 'no_competition_added', 'everyone_sees', 'any_competition_may_use', 'stops_running', 'nobody_can_start', 'clears_everyone', 'scores_kept', 'author_credit_void', 'reason_required', 'running_run_out', 'not_switched_yet', 'running_keep_old', 'only_competitions', 'nobody_without_competition', 'running_outside_run_out', 'leaves_competitions', 'no_new_starts', 'hidden_from_lists', 'credit_kept', 'scores_removed', 'admin_may_undo', 'back_to_unused', 'deleted_for_good'] = PydanticUndefined
count: int = 0
teams: int = 0
class AuthoredAudience(ctfy.core.models.CtfyModel):
176class AuthoredAudience(CtfyModel):
177    """Who can do this upload right now, as one sentence (#2740 D6, #2756).
178
179    Computed by the server from the facts the real visibility rules read
180    (`ctfy.core.authored_audience`); the browser renders
181    ``common:authoredAudience.<kind>`` and decides nothing.
182    """
183
184    kind: AuthoredAudienceKind
185    #: ``reviewing`` only.
186    stage: AuthoredAudienceStage | None = None
187    #: ``blocked``: when it was taken down.
188    since: AwareDatetime | None = None
189    #: ``blocked``: the take-down's reason; ``reviewing`` + ``rejected``: the
190    #: reviewer's note. Verbatim.
191    reason: str = ""
192    #: ``everyone`` / ``competitions``: the competitions using it that this
193    #: reader may hear of, running first.
194    competitions: list[AuthoredAudienceCompetition] = Field(default_factory=list)
195    #: …and how many more this reader may not hear the name of. A count names
196    #: nothing, and leaving them out would make ``competitions`` read as
197    #: "nobody" when somebody can play it.
198    other_competitions: int = 0

Who can do this upload right now, as one sentence (#2740 D6, #2756).

Computed by the server from the facts the real visibility rules read (ctfy.core.authored_audience); the browser renders common:authoredAudience.<kind> and decides nothing.

kind: Literal['everyone', 'competitions', 'blocked', 'retired', 'reviewing', 'unused'] = PydanticUndefined
stage: Optional[Literal['checking', 'check_failed', 'waiting', 'rejected']] = None
since: pydantic.types.AwareDatetime | None = None
reason: str = ''
competitions: list[AuthoredAudienceCompetition] = PydanticUndefined
other_competitions: int = 0
class AuthoredAudienceCompetition(ctfy.core.models.CtfyModel):
156class AuthoredAudienceCompetition(CtfyModel):
157    """One competition whose players can do an upload (#2756)."""
158
159    competition_id: str
160    competition_title: str = ""
161    competition_title_i18n: LocalizedText = Field(default_factory=dict)
162    phase: AuthoredAudiencePhase
163    #: When its players can start (``upcoming`` only reads it).
164    starts_at: AwareDatetime | None = None
165    #: ``ended`` only: the competition's post-close setting (#2183) — whether
166    #: its players can still practise it, never scored, or not do it at all
167    #: (#2740 D3, #2762). ``None`` while it has not ended.
168    after_close: AfterClose | None = None
169    #: This competition has frozen what the upload is worth on its board —
170    #: difficulty, priced questions and weights (#2731 PR B, from its start).
171    #: A later version still changes what its players read, never what it
172    #: pays there; only an organiser's re-price does (#3193).
173    pricing_frozen: bool = False

One competition whose players can do an upload (#2756).

competition_id: str = PydanticUndefined
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
phase: Literal['upcoming', 'running', 'ended'] = PydanticUndefined
starts_at: pydantic.types.AwareDatetime | None = None
after_close: Optional[Literal['stop', 'practice']] = None
pricing_frozen: bool = False
class AuthoredAuditReport(ctfy.core.models.CtfyModel):
1185class AuthoredAuditReport(CtfyModel):
1186    """What `ctfy-admin challenge audit` says about one upload.
1187
1188    ⚠️ **An approval was a person reading code with no machine verdict
1189    beside it.** The audit is the tool the author is told to run and the
1190    platform never ran it, so the reviewer's evidence was the files —
1191    and META/STRUCT/EXPLOIT findings are precisely what reading nine
1192    files does not reliably catch.
1193
1194    ⚠️ ``outcome`` defaults to ``failed`` and the counts to zero, so a
1195    producer that forgets to fill this in reports *the audit did not
1196    run* rather than *the tree is clean*. The default must never be the
1197    reassuring claim.
1198    """
1199
1200    outcome: AuditOutcome = "failed"
1201    #: Counted over every finding the audit reported, **not** over the
1202    #: cut list below: totals that shrank with the page would tell a
1203    #: reviewer the tree is cleaner than it is.
1204    errors: int = 0
1205    warnings: int = 0
1206    findings: list[AuditFinding] = Field(default_factory=list)
1207    #: True when `findings` was cut. Said rather than implied — an
1208    #: oldest-surviving finding that cannot say it is not the last is
1209    #: the shape `traffic.live.retained` records.
1210    truncated: bool = False
1211    #: The package's own ``standard:`` line, verbatim — ``""`` when it
1212    #: declares none (CTFY-0002 §2.8).
1213    standard_declared: str = ""
1214    #: Whose choice the checked version was: ``undeclared`` (the platform
1215    #: used its current version), ``current``, ``older`` (a supported
1216    #: earlier version — advice, never a refusal) or ``unsupported`` (an
1217    #: upload is refused for it; the audit ran against the current one).
1218    #: ⚠️ Defaults to ``undeclared`` with ``standard_checked`` 0: a producer
1219    #: that forgot to read the tree says *not recorded*, never a version.
1220    standard_reading: StandardReading = "undeclared"
1221    #: The version of CTFY-0002 the tree was checked against; ``0`` when
1222    #: the audit could not read the tree at all.
1223    standard_checked: int = 0
1224    #: Versions newer than the declared one, oldest first — what an author
1225    #: on an older version has not yet read. ``[]`` unless ``older``.
1226    standard_behind: list[int] = Field(default_factory=list)

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
standard_declared: str = ''
standard_reading: Literal['undeclared', 'current', 'older', 'unsupported'] = 'undeclared'
standard_checked: int = 0
standard_behind: list[int] = PydanticUndefined
class AuthoredAutoSmoke(ctfy.core.models.CtfyModel):
1088class AuthoredAutoSmoke(CtfyModel):
1089    """What the platform did about smoking one upload automatically.
1090
1091    ``docs/community-authoring-plan.md`` P2.2. A fact about the *upload
1092    request*, not about the row — which is why it rides only on the write
1093    response and is never stored: the verdict the run produces lands on
1094    the row as ``smoke_verdict``, like any other smoke run's.
1095    """
1096
1097    outcome: AuthoredAutoSmokeOutcome
1098    #: The runs allowed in the window: the person's per rolling day, or —
1099    #: under automatic review (#1965 C) — the team's per authoring window
1100    #: (``charged_to``). ``None`` where no budget was consulted (``off``,
1101    #: ``no_pool``, ``nothing_to_run``).
1102    budget: int | None = None
1103    #: Whose allowance this run was charged to: ``person`` (the platform's
1104    #: rolling-day budget) or ``team`` (the competition's per-window quota
1105    #: under automatic review). ``None`` where nothing was charged.
1106    charged_to: Literal["person", "team"] | None = None
1107    #: Runs left in the window after this upload's. ``queued`` and
1108    #: ``waiting_for_node`` only.
1109    remaining: int | None = None
1110    #: When the oldest run in the window leaves it, which is when the
1111    #: next upload can be smoked. ``budget_spent`` only.
1112    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', 'waiting_for_node', 'budget_spent', 'off', 'no_pool', 'nothing_to_run'] = PydanticUndefined
budget: int | None = None
charged_to: Optional[Literal['person', 'team']] = None
remaining: int | None = None
available_at: pydantic.types.AwareDatetime | None = None
class AuthoredBuildCapability(ctfy.core.models.CtfyModel):
1336class AuthoredBuildCapability(CtfyModel):
1337    """Whether an approval on this deployment can become a live challenge.
1338
1339    ⚠️ **Composed server-side, for `expects_report`'s reason.** Both
1340    facts — is anything marked, does the push exist — could be shipped
1341    separately and `&&`-ed in the browser, and then two clients disagree
1342    about whether an approval means anything. One answer, one composer.
1343
1344    ⚠️ **This exists because the page is where an operator forms the
1345    belief.** `PlatformSettingInfo.in_force` records the same shape one
1346    layer along: a setting that resolves ``true`` and does nothing is
1347    read as a gate that is running. Here the equivalent is an operator
1348    pressing Approve and going to look for a challenge that is not in
1349    front of anybody.
1350
1351    ⚠️ ``can_build`` defaults **False** and the client reads it as such:
1352    an older server that does not send this field has no untrusted-build
1353    axis at all, so the honest reading of its silence is *no*. That is
1354    the opposite default from most gates here, and deliberately — the
1355    open direction would have the page promise a deployment capability
1356    nothing implements.
1357    """
1358
1359    #: True only when a marked node exists **and** the push is built.
1360    #: Today the second half is always false; the field is not a
1361    #: constant because the first half is what an operator changes.
1362    can_build: bool = False
1363    #: Why not. ``""`` only when ``can_build`` is true.
1364    blocked_reason: AuthoredBuildBlockedReason = "not_implemented"
1365    #: How many healthy nodes carry the label. ``0`` beside
1366    #: ``no_untrusted_build_node`` is the actionable case: mark one.
1367    #: Counted rather than named, because a node id is an operator's
1368    #: internal topology and the queue page is not where it is read.
1369    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):
2455class AuthoredChallengeFileBody(CtfyModel):
2456    """One file's contents, for the operator who has to read it.
2457
2458    ⚠️ **This is the point of the whole queue.** An approval is a person
2459    saying "I read somebody else's code and it may run on our nodes";
2460    without a way to read it the verdict is a rubber stamp, and the
2461    button would be worse than no button. The compose gate
2462    (`ctfy.core.authored_compose`) rules on the declarative surface at
2463    the door — a ``Dockerfile``'s ``RUN`` is exactly what no key list can
2464    see, and this is where a human sees it.
2465
2466    ⚠️ **Plain text, never markup, and never a raw byte stream.**
2467    ``LlmCallBody``'s rule for prose an untrusted image wrote applies
2468    with more force here, because this text was written to be run: a
2469    response the browser may sniff as HTML is stored XSS on an admin
2470    page, served from the platform's own origin.
2471
2472    ⚠️ **``binary`` and ``truncated`` are separate facts and both are
2473    published.** Decoding a compiled artifact with replacement
2474    characters would show a reviewer mojibake and let them believe they
2475    had read the file; a silent cut would let them believe they had read
2476    all of it. Neither is a refusal — a reviewer who cannot open one
2477    file must still be able to rule on the tree.
2478    """
2479
2480    path: str
2481    #: The file's real size, always — so a truncated body still says how
2482    #: much was not shown.
2483    size_bytes: int = 0
2484    #: Decoded text, empty when ``binary``.
2485    text: str = ""
2486    binary: bool = False
2487    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):
1639class AuthoredChallengeFileEntry(CtfyModel):
1640    """One file in an upload, as the detail page lists it."""
1641
1642    path: str
1643    size_bytes: int = 0
1644    #: Where this file runs, if anywhere (plan P2.3): ``node`` — the
1645    #: platform builds or runs it on its own machines — or ``player`` —
1646    #: an executable or archive players download and run on theirs.
1647    #: ``""`` is *runs nowhere*. Structural, from
1648    #: `ctfy.core.execution_surface`: the compose file and the files'
1649    #: first bytes.
1650    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):
2490class AuthoredChallengeFileRequest(CtfyModel):
2491    """Which file of a stored tree to read (#1929).
2492
2493    ⚠️ **In the body, never in the URL.** The file routes took the member
2494    path as a ``{path:path}`` segment, and a campus WAF in front of one
2495    deployment blocks any request whose URL path ends in ``.ini`` — and
2496    decodes ``%2E`` before matching, so encoding is no way round it. A
2497    reviewer there could not read the ``php.ini`` they were reviewing. A
2498    path-sniffing filter does not read a JSON body.
2499
2500    ⚠️ **A dictionary key, never a filesystem path**: nothing joins it to a
2501    directory (`_file` looks it up in the stored tree), so it is not a
2502    ``PathSegment`` sink. The bound is only a bound on the request.
2503    """
2504
2505    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):
1115class AuthoredChallengeInfo(AuthoredChallengeRow):
1116    """One upload plus the paths in it — what a *write* answers with.
1117
1118    ⚠️ **A subclass rather than a second model**, which is what keeps
1119    `admin_awd._info`'s defect from being re-earned: every shared field
1120    reaches both shapes by construction, so a field added to the listing
1121    row cannot be forgotten here. The one composer builds the base and
1122    exactly one caller adds ``files``.
1123
1124    ⚠️ **Only where the tree is already in hand.** The upload route just
1125    wrote the row, so the paths are free there; the listing holds
1126    :class:`ctfy.core.authored_challenges.AuthoredChallengeSummary`,
1127    which by construction has no tree — and an empty ``files`` on a
1128    listing row would be a *claim* that the upload has no files, which
1129    is the shape-5 reading this split exists to avoid.
1130    """
1131
1132    #: Sorted paths relative to the benchmark directory.
1133    files: list[str] = Field(default_factory=list)
1134    #: Whether this upload was smoked on the quarantine pool as it arrived
1135    #: (P2.2). ⚠️ ``None`` is *not reported* — an older server, or a
1136    #: queueing step that failed after the upload was stored — never
1137    #: *not smoked*: the withheld value must not be a new claim.
1138    auto_smoke: AuthoredAutoSmoke | None = None
1139    #: #1969 — what this upload declares about the internet
1140    #: (``x-ctfy-internet`` in its compose file), and what its competition
1141    #: will give a player's instance of it. ``internet_conflict`` is the
1142    #: upload-time warning: the tree says it needs the internet and the
1143    #: event denies it, so as written it may not be solvable there. The
1144    #: organiser's rule stands; the organiser sees the same conflict.
1145    internet_required: bool = False
1146    internet_reason: str = ""
1147    internet: InternetAccess = InternetAccess.ALLOW
1148    internet_conflict: bool = False
1149    #: #3109: this upload minted a **new** challenge, and these of the
1150    #: author's own uploads in the competition carry the same title — the
1151    #: duplicate warning. Empty on a replace, and on a tree with no title.
1152    same_title: list[AuthoredSameTitle] = Field(default_factory=list)

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
internet_required: bool = False
internet_reason: str = ''
internet: ctfy.core.enums.InternetAccess = <InternetAccess.ALLOW: 'allow'>
internet_conflict: bool = False
same_title: list[AuthoredSameTitle] = PydanticUndefined
class AuthoredChallengeListRow(ctfy.server.models.AuthoredChallengeRow):
955class AuthoredChallengeListRow(AuthoredChallengeRow):
956    """`AuthoredChallengeRow` on the author's own listing, with its AI column
957    (#3093).
958
959    ⚠️ **A subclass, never a field on the base**: the upload's answer
960    (`AuthoredChallengeInfo`) extends the base, and a fresh upload has no
961    pre-check to carry — the no-public-reader walk would rightly find it there.
962    """
963
964    #: ``None`` where the event keeps the pre-check from its authors
965    #: (``authoring_authors_read_model_review`` off), so the page draws no
966    #: column; otherwise the newest pre-check of any version.
967    ai_precheck: AuthoredProbeCell | None = None

AuthoredChallengeRow on the author's own listing, with its AI column (#3093).

⚠️ A subclass, never a field on the base: the upload's answer (AuthoredChallengeInfo) extends the base, and a fresh upload has no pre-check to carry — the no-public-reader walk would rightly find it there.

ai_precheck: AuthoredProbeCell | None = None
class AuthoredChallengeReception(ctfy.core.models.CtfyModel):
2784class AuthoredChallengeReception(CtfyModel):
2785    """How players have done against one of the caller's own uploads (#2037 A2).
2786
2787    Composed from :func:`ctfy.core.challenge_reception.reception` — the
2788    credited-challenges producer — so a finish means the same thing here as on
2789    a contributor's page. ``tried`` is entrants (a team in one competition)
2790    that submitted to or solved it; a launch with no answer is not counted,
2791    which is why the page says *tried*, not *launched*.
2792
2793    ⚠️ ``withheld`` is a live silent exercise curating it: every number is
2794    then ``None`` — **emptied, never zeroed**, since a ``0`` would be a claim
2795    that nobody has finished it.
2796    """
2797
2798    challenge_id: str
2799    withheld: bool = False
2800    tried: int | None = None
2801    solves: int | None = None
2802    first_solved_at: AwareDatetime | None = None
2803    #: Largest first. ``None`` when reactions are not readable by this reader.
2804    reactions: list[ReactionCount] | None = None

How players have done against one of the caller's own uploads (#2037 A2).

Composed from ctfy.core.challenge_reception.reception() — the credited-challenges producer — so a finish means the same thing here as on a contributor's page. tried is entrants (a team in one competition) that submitted to or solved it; a launch with no answer is not counted, which is why the page says tried, not launched.

⚠️ withheld is a live silent exercise curating it: every number is then None — emptied, never zeroed, since a 0 would be a claim that nobody has finished it.

challenge_id: str = PydanticUndefined
withheld: bool = False
tried: int | None = None
solves: int | None = None
first_solved_at: pydantic.types.AwareDatetime | None = None
reactions: list[ReactionCount] | None = None
class AuthoredChallengeReviewRequest(ctfy.core.models.CtfyModel):
2508class AuthoredChallengeReviewRequest(CtfyModel):
2509    """An operator's verdict on one upload.
2510
2511    ⚠️ **A verdict can be revised**, as on ``review_harness``: a route
2512    that only moved forward would leave a wrongly-approved tree
2513    approved until somebody deleted the row.
2514    """
2515
2516    status: ReviewStatus
2517    #: What the author reads. Required on a rejection — see
2518    #: ``review_authored_challenge``.
2519    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):
731class AuthoredChallengeRow(AuthoredDeclarations):
732    """One upload as the author's own listing shows it.
733
734    ⚠️ **The bytes do not travel, and now neither do the paths.** A tree
735    is up to ``MAX_TOTAL_BYTES`` and a listing is every upload the team
736    has ever made, so this model decides what that page costs. It used
737    to carry ``files: list[str]`` and the route therefore had to select
738    ``content_b64`` to produce them — measured at the 32 MiB ceiling of
739    the time, 85 MB of peak per upload and 427 MB for five, to render a
740    file **count**.
741
742    ⚠️ **The paths kept a reader, and finding it is what split this in
743    two.** The first version of this change deleted ``files`` outright,
744    on a measurement of the *production* surfaces: the page and the CLI
745    take a length and nothing reads a name. That population was the
746    wrong one — `test_mcp_authoring_tools` asserts on the **paths**
747    through the pack/unpack round trip, and its own comment says why a
748    count cannot stand in ("a packer that wrote the wrapping component
749    twice, or none, still sends three files"). So the names live on
750    :class:`AuthoredChallengeInfo`, which the **upload** answers with,
751    where the tree is already in hand and costs nothing.
752    """
753
754    id: str
755    #: What the author called it, and what they address it by on every
756    #: surface. The id is minted, so this is the only half of the old
757    #: id that was theirs.
758    #:
759    #: ⚠️ **Unique per ``(competition_id, team_id)`` and no further.**
760    #: The id was globally unique, so one team taking ``vault`` denied
761    #: that name to every other team on the platform; the route had a
762    #: refusal for it and does not need one now.
763    slug: str = ""
764    #: ``metadata.yaml``'s own ``name:``, read at ingest. ``""`` means
765    #: the metadata did not say, and a reader falls back to `slug` —
766    #: never to the id, which is what this whole split removes from
767    #: anybody's screen.
768    title: str = ""
769    competition_id: str
770    team_id: str
771    #: Who pressed upload. Present so a captain can see which team-mate
772    #: sent this; the team is what gets the credit.
773    #:
774    #: ⚠️ **A uuid is not a reader**, which is why the field beside it
775    #: exists: this comment named an audience for the whole life of the
776    #: feature and no surface rendered either half. `AdminHarnessRow`
777    #: records the same finding one queue over — "the author is a uuid",
778    #: and a table of uuids is a table nobody triages.
779    user_id: str
780    #: The uploader's own display name, composed server-side.
781    #:
782    #: ⚠️ **Never their email**, which is where this parts company with
783    #: `admin_authoring._display_name`: that one falls back to the
784    #: address because its reader is an operator deciding whether to run
785    #: somebody's code, and `TeamMemberInfo` — the roster this same
786    #: squad already reads — publishes a display name and no address.
787    #: One shared helper would impose the operator's answer on a page
788    #: every team-mate can open.
789    #:
790    #: ⚠️ ``""`` means *no name to show* — the account is gone, or that
791    #: person never set one. The id travels beside it, so a reader can
792    #: still say "somebody on this team"; collapsing the two into the id
793    #: is what `_display_name`'s own docstring refuses.
794    author_display_name: str = ""
795    submitted_at: AwareDatetime | None = None
796    #: How many files the tree holds, and how many bytes they decode
797    #: to — so an author can tell a tree that arrived from one that
798    #: arrived empty without downloading it back.
799    #:
800    #: ⚠️ **These are stored columns, and they are what let the listing
801    #: stop reading the tree.** Producing them from ``content_b64`` is
802    #: what made the author's own listing select every byte their team
803    #: had ever uploaded: measured at a 32 MiB ``MAX_TOTAL_BYTES``, 85 MB
804    #: of peak for one upload and 427 MB for five, to render a count.
805    file_count: int = 0
806    total_bytes: int = 0
807    review_status: ReviewStatus = "pending"
808    reviewed_at: AwareDatetime | None = None
809    #: Why it was turned down. ``""`` while nobody has ruled.
810    review_note: str = ""
811    #: What running this tree's own reference exploit on a worker found,
812    #: or ``""`` if nobody has run one.
813    #:
814    #: ⚠️ **The author is the audience, and this is the half the audit
815    #: cannot produce.** `AuthoredAuditReport` reads files, so a
816    #: reference exploit that returns ``{}`` passes it clean — and an
817    #: unsolved board is what a hard challenge looks like too. Whoever
818    #: wrote the exploit is the only person who can fix that, and until
819    #: now the verdict reached an operator's task record and stopped.
820    #:
821    #: ⚠️ **Defaults to `""`, which claims nothing.** Any named member
822    #: would tell an author something about a run that never happened,
823    #: and `unsolved` in particular would accuse them of it.
824    smoke_verdict: AuthoredSmokeState = ""
825    #: When that run finished. Freshness — whether a verdict exists is
826    #: ``smoke_verdict``'s question, not a second field's.
827    smoke_at: AwareDatetime | None = None
828    #: ⚠️ **The platform's problem, never a verdict** (#1965 C3): why the
829    #: platform could not finish the last smoke run of this tree — a node
830    #: lost, a check that could not run (``errored``). ``""`` when the last
831    #: run finished or none was tried. Nothing was charged for it, and it
832    #: counts toward no pause; the platform retries it under automatic
833    #: review.
834    smoke_problem: str = ""
835    #: ⚠️ **The tree no longer passes the platform's rules** (#2113): the coded
836    #: rules a node refused it by. Not a verdict on the exploit and not a
837    #: platform fault — the files need a revision (or a re-upload), and until
838    #: then the automatic smoke and re-check leave it alone. ``[]`` is *not
839    #: refused*. Rendered as ``common:refusal.<code>``.
840    tree_refusals: list[AuthoringRefusal] = Field(default_factory=list)
841    #: A smoke run of this tree that has not produced its verdict yet:
842    #: ``queued`` waits its turn, ``running`` is on a worker now (leftover
843    #: L1). ``smoke_verdict`` above is still the last *finished* run's.
844    #:
845    #: ⚠️ **Why it exists**: the upload response says a run is queued, and
846    #: the listing then read "Not run" for as long as the run waited — the
847    #: one state that sends an author to upload again, spending another
848    #: run. Read off the task table (`ctfy.core.smoke_runs`), never stored.
849    #:
850    #: ⚠️ ``""`` is *no run in flight*, and also what a producer that did
851    #: not ask says: the display the row had before this field, never a
852    #: promise that a verdict is coming.
853    smoke_in_flight: AuthoredSmokeInFlightState = ""
854    #: Whether players can actually launch it: the tree is in this
855    #: deployment's catalog, read off the disk.
856    #:
857    #: ⚠️ **``review_status`` was answering three questions and could
858    #: only answer one.** An author read *approved* for a challenge
859    #: never published, for one that is live, and — once the removal
860    #: verb landed — for one taken back off the shelf. That is
861    #: `authored_publish`'s own recorded defect ("an operator who reads
862    #: it as *it is live* goes looking for a challenge that is not
863    #: there") pointed at the person who asks why their challenge is
864    #: not on the board.
865    #:
866    #: ⚠️ **Not the same as being in the event's challenge set**, and
867    #: the copy must not blur them: this is the catalog, while whether
868    #: an organiser curated it into ``challenge_ids`` is the field
869    #: below. A challenge can be live in the catalog and in no
870    #: competition.
871    #:
872    #: ⚠️ Defaults **False**, the loud direction and the admin row's:
873    #: an older server, or a producer that did not ask, reports *not
874    #: live*, which sends an author to ask rather than to assume.
875    in_catalog: bool = False
876    #: Where it stands with **this event's** challenge set — the step
877    #: after the one above, and the last one before entrants see it.
878    #:
879    #: ⚠️ **The sentence this replaced named a reader that does not
880    #: exist for most of this field's population.** ``in_catalog``'s
881    #: comment used to send the reader to
882    #: `AuthoredReceptionState.not_curated`, and measured, that route
883    #: answers ``{"scored": false, "rows": []}`` for every event that pays
884    #: nothing for authoring (no ``peer_authoring`` rule, no published
885    #: ``authoring`` board) — `authoring_enabled` is its own axis, so
886    #: an organiser collecting uploads on a jeopardy event leaves the
887    #: author with **no** surface for this at all.
888    #:
889    #: ⚠️ And the asymmetry ran the wrong way: `AdminAuthoredChallengeRow`
890    #: has carried `curation` since the operator's queue learned it,
891    #: composed from the competition row and therefore rule-independent.
892    #: The person who can *act* on it was told and the person *waiting*
893    #: on them was not.
894    #:
895    #: ⚠️ Same producer as the operator's (`server/authoring_curation.py`),
896    #: so the two cannot answer one upload differently — which is the
897    #: whole reason that module exists.
898    #:
899    #: ⚠️ Defaults ``not_curated``, the unfinished-looking reading: a
900    #: producer that did not ask must not tell an author their challenge
901    #: is in front of entrants.
902    curation: AuthoredCurationState = "not_curated"
903    #: The competitions **other than this upload's own** whose curated
904    #: set lists it — where it went after the event it was written for
905    #: (plan P1.5). ``curation`` above stays the own-event answer.
906    #:
907    #: ⚠️ From the **same producer** for both audiences
908    #: (`authoring_curation.curated_elsewhere`), `curation`'s rule — but
909    #: the author's copy names only competitions the author may see, so
910    #: an upload is not how a hidden event's name reaches somebody it is
911    #: hidden from. The operator's copy names all of them.
912    #:
913    #: ⚠️ Empty by default, and empty is a real answer: nothing curated
914    #: it anywhere else. A bucket competition never appears, because
915    #: nothing is curated into a set resolved from a catalog directory.
916    curated_elsewhere: list[AuthoredCurationElsewhere] = Field(default_factory=list)
917    #: The revision proposed for this upload and not yet applied — waiting,
918    #: approved or turned down — or ``None`` when there is none (plan P3.1).
919    #:
920    #: ⚠️ ``None`` is also what a producer that did not ask says, and it
921    #: claims nothing: the row is the live version either way.
922    revision: AuthoredRevisionRow | None = None
923    #: What the platform decided under automatic review (#1965 C), and the
924    #: reason when it rejected or held the upload — see
925    #: :data:`ctfy.core.enums.AutomaticOutcome`. ``review_note`` carries the
926    #: same reason in English. ``""`` is *the platform decided nothing*.
927    automatic_outcome: AutomaticOutcomeState = ""
928    #: ⚠️ **No model review here, nor anywhere an author reads** (#2830,
929    #: owner decision 2026-10-30): its verdict is the reviewers' and the
930    #: organisers' (`AdminModelReview`). An author who could read it would
931    #: re-upload until the model read ``clean``.
932    #: Where what this upload has waiting for review stands in its
933    #: competition's queue (#2037): 1 is next. The upload itself while it is
934    #: ``pending``, else its waiting revision — the queue is worked oldest
935    #: first, and new uploads and revisions are separate queues. ``None`` is
936    #: *nothing of this upload is waiting*, never *first*.
937    queue_position: int | None = None
938    #: How long that queue is, beside the position (``0`` with no position).
939    queue_length: int = 0
940    #: The version players are on (``0`` = none yet), the open proposal's
941    #: version, and how many notes the reviewers left across the upload's
942    #: threads — the listing's per-row facts (#2037), counted in one grouped
943    #: read for the page (`authored_version_facts`).
944    #:
945    #: ⚠️ ``0`` means *none / not asked*, never a claim: a producer that did
946    #: not ask draws no "v3", no "N notes".
947    live_version: int = 0
948    proposed_version: int = 0
949    reviewer_note_count: int = 0
950    #: Who can do it right now (#2756), from the server's real visibility
951    #: facts. ``None`` is *not asked*; the browser then says nothing.
952    who_can_play: AuthoredAudience | 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_problem: str = ''
tree_refusals: list[ctfy.core.authoring_refusals.AuthoringRefusal] = PydanticUndefined
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
automatic_outcome: Union[Literal[''], Literal['approved', 'rejected_unsolved', 'rejected_estate_failed', 'rejected_malformed', 'rejected_duplicate', 'rejected_injection', 'held_answers_not_minted', 'held_no_dedicated_pool', 'held_not_publishable', 'held_not_curatable', 'held_smoke_unavailable', 'held_malice']] = ''
queue_position: int | None = None
queue_length: int = 0
live_version: int = 0
proposed_version: int = 0
reviewer_note_count: int = 0
who_can_play: AuthoredAudience | None = None
class AuthoredChangedFile(ctfy.core.models.CtfyModel):
2060class AuthoredChangedFile(CtfyModel):
2061    """One path a version changed against the tree it was built on (#3002)."""
2062
2063    path: str
2064    change: AuthoredFileChange

One path a version changed against the tree it was built on (#3002).

path: str = PydanticUndefined
change: Literal['added', 'removed', 'modified'] = PydanticUndefined
class AuthoredCheckProblem(ctfy.core.models.CtfyModel):
970class AuthoredCheckProblem(CtfyModel):
971    """One rule a dry-run check found broken — the response the upload door
972    would answer with for it: ``status`` and ``code`` its envelope's, and
973    ``refusals`` the coded reasons under it (empty for a rule with one
974    sentence, like the contributor agreement)."""
975
976    status: int
977    code: str
978    message: str
979    refusals: list[AuthoringRefusal] = Field(default_factory=list)

One rule a dry-run check found broken — the response the upload door would answer with for it: status and code its envelope's, and refusals the coded reasons under it (empty for a rule with one sentence, like the contributor agreement).

status: int = PydanticUndefined
code: str = PydanticUndefined
message: str = PydanticUndefined
refusals: list[ctfy.core.authoring_refusals.AuthoringRefusal] = PydanticUndefined
class AuthoredCheckReport(ctfy.core.models.CtfyModel):
1054class AuthoredCheckReport(CtfyModel):
1055    """What uploading this tree would be told, without uploading it (#1965).
1056
1057    ``ok`` is ``problems == []``: the upload door's form and tree rules all
1058    pass, so an upload of these bytes with these fields will not be refused
1059    for a static reason. It may still be refused for a state that can change
1060    between the two — the authoring window, the review queue's caps, the
1061    team's smoke quota, the rejection pause — which this check deliberately
1062    does not ask: it creates nothing, charges nothing and counts toward
1063    nothing.
1064
1065    ``audit`` is the advisory audit's findings over the same tree, as the
1066    review queue shows them — advice, never a refusal. ``None`` when the
1067    archive could not be read.
1068    """
1069
1070    ok: bool
1071    problems: list[AuthoredCheckProblem] = Field(default_factory=list)
1072    audit: AuthoredAuditReport | None = None
1073    #: Advice from the static rules, never a refusal and never part of ``ok``
1074    #: (#2080): a service declaring no CPU or memory limit, which the budget
1075    #: check cannot count.
1076    advisories: list[AuthoringRefusal] = Field(default_factory=list)
1077    #: The team's upload allowance right now, read without charging it
1078    #: (#2079); ``None`` = the event has no limit.
1079    upload_rate: AuthoringRateInfo | None = None
1080    #: The team's per-window quota of new uploads, read without charging it;
1081    #: ``None`` = no per-window limit here (or no window open now).
1082    upload_window: AuthoringWindowQuotaInfo | None = None
1083    #: #3109: your own uploads here under this tree's title, when an upload of
1084    #: it would mint a new challenge. Advice, never part of ``ok``.
1085    same_title: list[AuthoredSameTitle] = Field(default_factory=list)

What uploading this tree would be told, without uploading it (#1965).

ok is problems == []: the upload door's form and tree rules all pass, so an upload of these bytes with these fields will not be refused for a static reason. It may still be refused for a state that can change between the two — the authoring window, the review queue's caps, the team's smoke quota, the rejection pause — which this check deliberately does not ask: it creates nothing, charges nothing and counts toward nothing.

audit is the advisory audit's findings over the same tree, as the review queue shows them — advice, never a refusal. None when the archive could not be read.

ok: bool = PydanticUndefined
problems: list[AuthoredCheckProblem] = PydanticUndefined
audit: AuthoredAuditReport | None = None
advisories: list[ctfy.core.authoring_refusals.AuthoringRefusal] = PydanticUndefined
upload_rate: AuthoringRateInfo | None = None
upload_window: AuthoringWindowQuotaInfo | None = None
same_title: list[AuthoredSameTitle] = PydanticUndefined
class AuthoredCompetitionRow(ctfy.core.models.CtfyModel):
201class AuthoredCompetitionRow(CtfyModel):
202    """One competition an upload is curated into, with what players did there
203    (#2037) — the "in which competitions" table on the single-upload page.
204
205    ``instance_count`` is the launch count for this challenge in that
206    competition: live, stopped-but-unarchived, and archived launches, each
207    counted once by instance id. ``solve_count`` is how many entrants there
208    fully solved it. Both are counted in the backend over the whole set,
209    never per row.
210    """
211
212    competition_id: str
213    #: ``""`` only if the competition had no title — a reader falls back to
214    #: the id, `AuthoredCurationElsewhere`'s rule.
215    competition_title: str = ""
216    #: The competition's title per locale (#2230); a browser renders this,
217    #: ``competition_title`` is its base-locale reading.
218    competition_title_i18n: LocalizedText = Field(default_factory=dict)
219    #: Instances of this challenge started in this competition: live,
220    #: stopped-but-unarchived, and archived, deduplicated by instance id.
221    instance_count: int = 0
222    #: Entrants who hold every declared question of this challenge there.
223    solve_count: int = 0

One competition an upload is curated into, with what players did there (#2037) — the "in which competitions" table on the single-upload page.

instance_count is the launch count for this challenge in that competition: live, stopped-but-unarchived, and archived launches, each counted once by instance id. solve_count is how many entrants there fully solved it. Both are counted in the backend over the whole set, never per row.

competition_id: str = PydanticUndefined
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
instance_count: int = 0
solve_count: int = 0
class AuthoredCurationElsewhere(ctfy.core.models.CtfyModel):
137class AuthoredCurationElsewhere(CtfyModel):
138    """Another competition an upload has been curated into.
139
140    ``docs/community-authoring-plan.md`` P1.5: the workshop → arena step
141    curates an upload into a competition that is not its own, and the
142    author's page has to be able to say it graduated. The competition's
143    own id and title, because the title is what a person reads and the
144    id is what a client links to.
145    """
146
147    competition_id: str
148    #: ``""`` only if the competition had no title — every reader then
149    #: renders the id.
150    competition_title: str = ""
151    #: The competition's title per locale (#2230); a browser renders this,
152    #: ``competition_title`` is its base-locale reading.
153    competition_title_i18n: LocalizedText = Field(default_factory=dict)

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 = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
AuthoredCurationState = typing.Literal['curated', 'not_curated', 'auto_bucket']
class AuthoredDeclarations(ctfy.core.models.CtfyModel):
226class AuthoredDeclarations(CtfyModel):
227    """What an author declared about an upload under the contributor
228    agreement, and which agreement they accepted
229    (`docs/community-authoring-plan.md` P1.3).
230
231    The base of every row model that describes an upload, so a field
232    added here reaches the author's listing and the operator's queue
233    alike — the composers carry by field-set intersection.
234
235    ⚠️ ``""`` on ``license``, ``provenance`` and ``ai_assisted`` is
236    **nobody said** — an upload from before the agreement existed, or to
237    an event that asks for none — and a reader takes the strictest
238    meaning: platform only, not known. It is never shown as an answer.
239    The two grants default to their refusal instead: no evaluation use,
240    anonymous credit.
241    """
242
243    #: One of six licences (decision 9), or ``""`` for none declared.
244    license: AuthoredLicenseState = ""
245    #: ``original`` or ``port``, or ``""`` for none declared.
246    provenance: AuthoredProvenanceState = ""
247    #: What a port was ported from; only a ``port`` carries one.
248    source_url: str = ""
249    #: ``yes`` / ``no``, or ``""`` — **not known**, never read as ``no``.
250    ai_assisted: AuthoredAiAssistanceState = ""
251    #: Whether the platform may use it to evaluate AI agents.
252    eval_use: bool = False
253    #: How the author is credited in public.
254    credit: AuthoredCredit = "anonymous"
255    #: The agreement version accepted at upload, stamped from the
256    #: competition; ``""`` when the event asked for none.
257    terms_version: str = ""
258    terms_accepted_at: AwareDatetime | None = None
259    #: Where it goes once published (plan P3.4): ``public``, the catalog
260    #: every competition may curate, or ``eval_only`` — the evaluation
261    #: pool, kept out of every public listing and launched only through a
262    #: competition that is not public and curates it. Only an original
263    #: upload whose author granted evaluation use may choose the pool.
264    destination: AuthoredDestination = "public"
265    #: The team acceptance this upload relied on (#1965 B) — the id of the
266    #: agreement the team's captain accepted for the whole team, read in
267    #: full at ``GET /competitions/{id}/authoring/agreement``. ``""`` is
268    #: *none relied on*: the upload accepted the agreement itself, or its
269    #: event names none.
270    team_agreement_id: str = ""

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'
team_agreement_id: str = ''
class AuthoredFlow(ctfy.core.models.CtfyModel):
1783class AuthoredFlow(CtfyModel):
1784    """Where one upload is in its lifecycle, what the caller can do next, and
1785    how it got here (#2147, ``ctfy.core.authored_flow``)."""
1786
1787    challenge_id: str
1788    state: AuthoredFlowState
1789    smoke_stage: AuthoredSmokeStage
1790    #: The AI pre-check of the current version (#3443): advisory, never a
1791    #: gate on review. ``hidden`` — the default — draws no step: this reader
1792    #: may not see the pre-check (`core.authored_precheck_step`).
1793    precheck: AuthoredPrecheckStage = "hidden"
1794    #: A reviewer has passed the upload in this state — the one fact the
1795    #: progress bar and the visibility badge need beyond the state's name.
1796    reviewed: bool = False
1797    audience: AuthoredFlowAudience
1798    transitions: list[AuthoredFlowTransitionInfo] = Field(default_factory=list)
1799    #: The states it has been in, in order, ending with ``state``.
1800    walked: list[AuthoredFlowState] = Field(default_factory=list)
1801    log: list[AuthoredFlowLogEntry] = Field(default_factory=list)
1802    #: Whether it was published for evaluation only (never into a public event).
1803    eval_only: bool = False
1804    #: Who can do it right now (#2756), from the server's real visibility
1805    #: facts. ``None`` is *not asked*; the browser then says nothing.
1806    who_can_play: AuthoredAudience | None = None

Where one upload is in its lifecycle, what the caller can do next, and how it got here (#2147, ctfy.core.authored_flow).

challenge_id: str = PydanticUndefined
state: Literal['uploaded', 'smoking', 'smoke_failed', 'smoke_errored', 'refused', 'pending', 'rejected', 'approved', 'published', 'live', 'regressed', 'revision', 'unpublished', 'retired'] = PydanticUndefined
smoke_stage: Literal['none', 'queued', 'running', 'passed', 'failed', 'errored', 'refused'] = PydanticUndefined
precheck: Literal['hidden', 'off', 'untried', 'waiting', 'running', 'done', 'partial', 'failed'] = 'hidden'
reviewed: bool = False
audience: Literal['author', 'staff'] = PydanticUndefined
transitions: list[AuthoredFlowTransitionInfo] = PydanticUndefined
walked: list[typing.Literal['uploaded', 'smoking', 'smoke_failed', 'smoke_errored', 'refused', 'pending', 'rejected', 'approved', 'published', 'live', 'regressed', 'revision', 'unpublished', 'retired']] = PydanticUndefined
log: list[AuthoredFlowLogEntry] = PydanticUndefined
eval_only: bool = False
who_can_play: AuthoredAudience | None = None
class AuthoredFlowGraph(ctfy.core.models.CtfyModel):
1826class AuthoredFlowGraph(CtfyModel):
1827    """The lifecycle's whole definition (#2147) — the same table the server
1828    derives every state and every transition from, so the diagram is drawn
1829    from it and not from a copy."""
1830
1831    states: list[AuthoredFlowStateDef]
1832    transitions: list[AuthoredFlowTransitionDef]

The lifecycle's whole definition (#2147) — the same table the server derives every state and every transition from, so the diagram is drawn from it and not from a copy.

states: list[AuthoredFlowStateDef] = PydanticUndefined
transitions: list[AuthoredFlowTransitionDef] = PydanticUndefined
class AuthoredFlowLogEntry(ctfy.core.models.CtfyModel):
1720class AuthoredFlowLogEntry(CtfyModel):
1721    """One line of the transition log: who, when, ``from_state`` → ``to_state``,
1722    and what they said (#2147).
1723
1724    ⚠️ **The author's copy names nobody but the author**: ``actor`` is the
1725    kind (``reviewer`` renders as the review group) and
1726    ``actor_display_name`` is ``""`` for every other kind — the version
1727    thread's rule, applied to the whole log.
1728    """
1729
1730    id: str
1731    at: AwareDatetime
1732    code: AuthoredFlowLogCode
1733    actor: AuthoredFlowActor
1734    actor_display_name: str = ""
1735    #: ``""`` for the first line (nothing came before the upload).
1736    from_state: Literal[""] | AuthoredFlowState
1737    to_state: AuthoredFlowState
1738    #: Free text a person wrote, verbatim (a reason, a version note).
1739    note: str = ""
1740    version: int = 0
1741    competition_id: str = ""
1742    competition_title: str = ""
1743    #: The competition's title per locale (#2230); a browser renders this,
1744    #: ``competition_title`` is its base-locale reading.
1745    competition_title_i18n: LocalizedText = Field(default_factory=dict)
1746    params: dict[str, str] = Field(default_factory=dict)

One line of the transition log: who, when, from_state → to_state, and what they said (#2147).

⚠️ The author's copy names nobody but the author: actor is the kind (reviewer renders as the review group) and actor_display_name is "" for every other kind — the version thread's rule, applied to the whole log.

id: str = PydanticUndefined
at: pydantic.types.AwareDatetime = PydanticUndefined
code: Literal['uploaded', 'smoke_passed', 'smoke_failed', 'smoke_errored', 'recheck_failed', 'recheck_recovered', 'recheck_passed', 'approved', 'rejected', 'published', 'restored', 'unpublished', 'retired', 'unretired', 'curated', 'uncurated', 'revision_submitted', 'revision_approved', 'revision_rejected', 'revision_withdrawn', 'revision_applied', 'rolled_back'] = PydanticUndefined
actor: Literal['author', 'reviewer', 'admin', 'organiser', 'system'] = PydanticUndefined
actor_display_name: str = ''
from_state: Union[Literal[''], Literal['uploaded', 'smoking', 'smoke_failed', 'smoke_errored', 'refused', 'pending', 'rejected', 'approved', 'published', 'live', 'regressed', 'revision', 'unpublished', 'retired']] = PydanticUndefined
to_state: Literal['uploaded', 'smoking', 'smoke_failed', 'smoke_errored', 'refused', 'pending', 'rejected', 'approved', 'published', 'live', 'regressed', 'revision', 'unpublished', 'retired'] = PydanticUndefined
note: str = ''
version: int = 0
competition_id: str = ''
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
params: dict[str, str] = PydanticUndefined
class AuthoredFlowStateDef(ctfy.core.models.CtfyModel):
1809class AuthoredFlowStateDef(CtfyModel):
1810    key: AuthoredFlowState
1811    step: Literal["uploaded", "smoke", "review", "visible"]
1812    tone: Literal["normal", "bad", "warn"]
1813    reviewed: 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.

key: Literal['uploaded', 'smoking', 'smoke_failed', 'smoke_errored', 'refused', 'pending', 'rejected', 'approved', 'published', 'live', 'regressed', 'revision', 'unpublished', 'retired'] = PydanticUndefined
step: Literal['uploaded', 'smoke', 'review', 'visible'] = PydanticUndefined
tone: Literal['normal', 'bad', 'warn'] = PydanticUndefined
reviewed: bool = False
class AuthoredFlowTransitionDef(ctfy.core.models.CtfyModel):
1816class AuthoredFlowTransitionDef(CtfyModel):
1817    action: AuthoredFlowAction
1818    from_states: list[AuthoredFlowState]
1819    to: AuthoredFlowState | None = None
1820    actors: list[AuthoredFlowActor]
1821    #: The states whose edge the diagram draws.
1822    drawn_from: list[AuthoredFlowState]
1823    restores: 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.

action: Literal['start_smoke', 'smoke_passes', 'smoke_fails', 'smoke_errors', 'tree_refused', 'smoke_retries', 'recheck_fails', 'recheck_passes', 'reupload', 'resmoke', 'approve', 'reject', 'reopen_review', 'publish', 'unpublish', 'republish', 'catalog_release', 'catalog_unrelease', 'retire', 'unretire', 'delete', 'curate', 'uncurate', 'submit_revision', 'withdraw_revision', 'approve_revision', 'reject_revision', 'apply_revision'] = PydanticUndefined
from_states: list[typing.Literal['uploaded', 'smoking', 'smoke_failed', 'smoke_errored', 'refused', 'pending', 'rejected', 'approved', 'published', 'live', 'regressed', 'revision', 'unpublished', 'retired']] = PydanticUndefined
to: Optional[Literal['uploaded', 'smoking', 'smoke_failed', 'smoke_errored', 'refused', 'pending', 'rejected', 'approved', 'published', 'live', 'regressed', 'revision', 'unpublished', 'retired']] = None
actors: list[typing.Literal['author', 'reviewer', 'admin', 'organiser', 'system']] = PydanticUndefined
drawn_from: list[typing.Literal['uploaded', 'smoking', 'smoke_failed', 'smoke_errored', 'refused', 'pending', 'rejected', 'approved', 'published', 'live', 'regressed', 'revision', 'unpublished', 'retired']] = PydanticUndefined
restores: bool = False
class AuthoredFlowTransitionInfo(ctfy.core.models.CtfyModel):
1698class AuthoredFlowTransitionInfo(CtfyModel):
1699    """One transition out of the upload's current state, for this caller
1700    (#2147). ``allowed`` is whether *this caller* may perform it now;
1701    ``actors`` is who may, so a disabled one can say who to ask, and
1702    ``refusals`` is why not when the role is not the reason (#2756)."""
1703
1704    action: AuthoredFlowAction
1705    #: Where it lands; ``None`` — the upload stays where it is.
1706    to: AuthoredFlowState | None = None
1707    actors: list[AuthoredFlowActor]
1708    allowed: bool
1709    #: Done by the platform on its own; nobody presses it.
1710    automatic: bool = False
1711    #: What pressing it does besides its sentence, as true right now
1712    #: (`ctfy.core.authored_actions`). Empty for the platform's own moves.
1713    effects: list[AuthoredActionEffectInfo] = Field(default_factory=list)
1714    #: Why this caller may not do it now although their role may: the same
1715    #: refusals the act's own door would answer with. Empty when ``allowed``
1716    #: or when the role is the reason (``actors`` says who can).
1717    refusals: list[AuthoringRefusal] = Field(default_factory=list)

One transition out of the upload's current state, for this caller (#2147). allowed is whether this caller may perform it now; actors is who may, so a disabled one can say who to ask, and refusals is why not when the role is not the reason (#2756).

action: Literal['start_smoke', 'smoke_passes', 'smoke_fails', 'smoke_errors', 'tree_refused', 'smoke_retries', 'recheck_fails', 'recheck_passes', 'reupload', 'resmoke', 'approve', 'reject', 'reopen_review', 'publish', 'unpublish', 'republish', 'catalog_release', 'catalog_unrelease', 'retire', 'unretire', 'delete', 'curate', 'uncurate', 'submit_revision', 'withdraw_revision', 'approve_revision', 'reject_revision', 'apply_revision'] = PydanticUndefined
to: Optional[Literal['uploaded', 'smoking', 'smoke_failed', 'smoke_errored', 'refused', 'pending', 'rejected', 'approved', 'published', 'live', 'regressed', 'revision', 'unpublished', 'retired']] = None
actors: list[typing.Literal['author', 'reviewer', 'admin', 'organiser', 'system']] = PydanticUndefined
allowed: bool = PydanticUndefined
automatic: bool = False
effects: list[AuthoredActionEffectInfo] = PydanticUndefined
refusals: list[ctfy.core.authoring_refusals.AuthoringRefusal] = PydanticUndefined
class AuthoredGraduationInfo(ctfy.core.models.CtfyModel):
1990class AuthoredGraduationInfo(CtfyModel):
1991    """Whether an upload may graduate into the challenge corpus, and as what.
1992
1993    ``docs/community-authoring-plan.md`` P3.3. The platform opens nothing:
1994    this is the answer a platform admin reads before downloading the bundle
1995    (``GET .../graduation/bundle``) that a person carries to the corpus
1996    repository as a pull request, whose own review is the gate.
1997    """
1998
1999    challenge_id: str
2000    #: Every reason it may not graduate yet, collected — empty when it may.
2001    #: A code a client renders in its reader's language, with the English
2002    #: beside it for one that has no copy for the code.
2003    refusals: list[AuthoringRefusal] = Field(default_factory=list)
2004    #: The corpus id it would take: the one asked for, or else the next
2005    #: ``COMMUNITY-NNN`` this deployment's catalog does not hold.
2006    corpus_id: str
2007    #: The licence its author declared, which the bundle's ``LICENSE``
2008    #: carries. ``""`` is nothing declared, which refuses.
2009    license: AuthoredLicenseState = ""
2010    #: The words for the pull request a person opens with the bundle.
2011    pull_request_title: str
2012    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 AuthoredHistory(ctfy.core.models.CtfyModel):
1770class AuthoredHistory(CtfyModel):
1771    """Everything that happened to one upload, oldest first, as one stream
1772    (#3093): upload, smoke, review verdicts and publication from the
1773    transition log, plus the AI pre-checks and the injection reviews.
1774
1775    ⚠️ **Organisers and reviewers only**: it carries the pre-check and the
1776    model review, so it lives under ``/api/v1/admin`` and nowhere else.
1777    """
1778
1779    challenge_id: str
1780    entries: list[AuthoredHistoryEntry] = Field(default_factory=list)

Everything that happened to one upload, oldest first, as one stream (#3093): upload, smoke, review verdicts and publication from the transition log, plus the AI pre-checks and the injection reviews.

⚠️ Organisers and reviewers only: it carries the pre-check and the model review, so it lives under /api/v1/admin and nowhere else.

challenge_id: str = PydanticUndefined
entries: list[AuthoredHistoryEntry] = PydanticUndefined
class AuthoredHistoryEntry(ctfy.core.models.CtfyModel):
1755class AuthoredHistoryEntry(CtfyModel):
1756    """One line of the review page's 「历史」 (#3093). Exactly the field
1757    ``kind`` names is set; ``version`` is the version it is about, ``0`` when
1758    it is about the whole upload or the bytes match no stored version."""
1759
1760    id: str
1761    at: AwareDatetime
1762    kind: AuthoredHistoryKind
1763    version: int = 0
1764    flow: AuthoredFlowLogEntry | None = None
1765    lifecycle: AuthoredLifecycleRow | None = None
1766    ai_precheck: ProbeBrief | None = None
1767    model_review: AdminModelReviewBrief | None = None

One line of the review page's 「历史」 (#3093). Exactly the field kind names is set; version is the version it is about, 0 when it is about the whole upload or the bytes match no stored version.

id: str = PydanticUndefined
at: pydantic.types.AwareDatetime = PydanticUndefined
kind: Literal['flow', 'lifecycle', 'ai_precheck', 'model_review'] = PydanticUndefined
version: int = 0
flow: AuthoredFlowLogEntry | None = None
lifecycle: AuthoredLifecycleRow | None = None
ai_precheck: ProbeBrief | None = None
model_review: AdminModelReviewBrief | None = None
class AuthoredLifecycleRow(ctfy.core.models.CtfyModel):
1653class AuthoredLifecycleRow(CtfyModel):
1654    """One publication fact about an upload (#1965 H) — the audit of who
1655    published it, took it down or replaced it in place, when, for which
1656    event and why. Oldest first on the detail; append-only underneath."""
1657
1658    id: str
1659    kind: AuthoredLifecycleKind
1660    at: AwareDatetime
1661    actor_user_id: str = ""
1662    #: What to call ``actor_user_id`` (``operator_label``); ``""`` beside a
1663    #: blank id, or for an account that is gone.
1664    actor_display_name: str = ""
1665    #: The event whose organiser acted, or whose automatic review
1666    #: published it; ``""`` is a platform admin acting platform-wide.
1667    competition_id: str = ""
1668    competition_title: str = ""
1669    #: The competition's title per locale (#2230); a browser renders this,
1670    #: ``competition_title`` is its base-locale reading.
1671    competition_title_i18n: LocalizedText = Field(default_factory=dict)
1672    #: Why. Always set on ``unpublished``; ``""`` elsewhere is *nobody
1673    #: said*.
1674    reason: str = ""
1675    #: The version the record is about.
1676    submitted_at: AwareDatetime | None = None
1677    tree_digest: str = ""
1678    #: What the remover chose for the points already scored on it (#2991):
1679    #: ``None`` where nothing was asked — no solves, another kind, or a
1680    #: record written before the choice existed.
1681    scores: AuthoredScoresChoice | None = None
1682    #: On ``merged`` / ``unmerged`` (#3110): the upload it was folded into.
1683    merged_into: str = ""

One publication fact about an upload (#1965 H) — the audit of who published it, took it down or replaced it in place, when, for which event and why. Oldest first on the detail; append-only underneath.

id: str = PydanticUndefined
kind: Literal['published', 'unpublished', 'revised', 'curated', 'uncurated', 'released', 'unreleased', 'policy_unreleased', 'retired', 'unretired', 'deleted', 'merged', 'unmerged'] = PydanticUndefined
at: pydantic.types.AwareDatetime = PydanticUndefined
actor_user_id: str = ''
actor_display_name: str = ''
competition_id: str = ''
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
reason: str = ''
submitted_at: pydantic.types.AwareDatetime | None = None
tree_digest: str = ''
scores: Optional[Literal['kept', 'removed']] = None
merged_into: str = ''
class AuthoredMaliceOverrideRequest(ctfy.core.models.CtfyModel):
716class AuthoredMaliceOverrideRequest(CtfyModel):
717    """Rule on a malice hold: ``clear`` releases it, ``confirm`` keeps it.
718    ``note`` is required: the audit's answer to *why*."""
719
720    decision: Literal["clear", "confirm"]
721    note: str = Field(min_length=1, max_length=2000)

Rule on a malice hold: clear releases it, confirm keeps it. note is required: the audit's answer to why.

decision: Literal['clear', 'confirm'] = PydanticUndefined
note: str = PydanticUndefined
class AuthoredModelReviewOverrideRequest(ctfy.core.models.CtfyModel):
724class AuthoredModelReviewOverrideRequest(CtfyModel):
725    """Say the flagged text is not an injection. ``note`` is required: it is
726    the audit's answer to *why*, beside who and when."""
727
728    note: str = Field(min_length=1, max_length=2000)

Say the flagged text is not an injection. note is required: it is the audit's answer to why, beside who and when.

note: str = PydanticUndefined
class AuthoredOperatorEdit(ctfy.core.models.CtfyModel):
2158class AuthoredOperatorEdit(CtfyModel):
2159    """One edit of an administrator's revision on top of the current tree
2160    (#3002): ``content_b64`` is the file's new bytes, base64, or ``null`` to
2161    delete it; ``executable`` its execute bit, ``null`` to keep the bit it
2162    has (a new file has none)."""
2163
2164    path: str = Field(min_length=1, max_length=1024)
2165    content_b64: str | None = None
2166    executable: bool | None = None

One edit of an administrator's revision on top of the current tree (#3002): content_b64 is the file's new bytes, base64, or null to delete it; executable its execute bit, null to keep the bit it has (a new file has none).

path: str = PydanticUndefined
content_b64: str | None = None
executable: bool | None = None
class AuthoredOperatorRevisionInfo(ctfy.core.models.CtfyModel):
2169class AuthoredOperatorRevisionInfo(CtfyModel):
2170    """What an administrator's revision stored (#3002): the new version, and
2171    — for an approved upload, whose new tree is a proposal — its revision.
2172    ``approved`` is whether the same press approved it."""
2173
2174    version: AdminAuthoredVersionRow
2175    revision_id: str = ""
2176    approved: bool = False

What an administrator's revision stored (#3002): the new version, and — for an approved upload, whose new tree is a proposal — its revision. approved is whether the same press approved it.

version: AdminAuthoredVersionRow = PydanticUndefined
revision_id: str = ''
approved: bool = False
class AuthoredOtherUpload(ctfy.core.models.CtfyModel):
1872class AuthoredOtherUpload(CtfyModel):
1873    """Another upload of the same challenge (#3110): same competition, same
1874    title, same team or author — what the fold panel lists beside a row."""
1875
1876    id: str
1877    slug: str = ""
1878    title: str = ""
1879    team_id: str = ""
1880    review_status: ReviewStatus
1881    smoke_verdict: AuthoredSmokeState = ""
1882    submitted_at: AwareDatetime | None = None
1883    #: The upload it is merged into now; ``""`` — its own challenge.
1884    merged_into: str = ""
1885    #: Live environments of it now — a merge waits for them.
1886    running_instances: int = 0
1887    #: Whether a revision of it waits for review — a merge waits for that too.
1888    open_revision: bool = False
1889    #: The competitions whose set holds it, which a merge takes it out of.
1890    competition_ids: list[str] = Field(default_factory=list)

Another upload of the same challenge (#3110): same competition, same title, same team or author — what the fold panel lists beside a row.

id: str = PydanticUndefined
slug: str = ''
title: str = ''
team_id: str = ''
review_status: Literal['pending', 'approved', 'rejected'] = PydanticUndefined
smoke_verdict: Union[Literal[''], Literal['solved', 'unsolved', 'estate_failed', 'malformed', 'errored']] = ''
submitted_at: pydantic.types.AwareDatetime | None = None
merged_into: str = ''
running_instances: int = 0
open_revision: bool = False
competition_ids: list[str] = PydanticUndefined
class AuthoredOtherUploads(ctfy.core.models.CtfyModel):
1893class AuthoredOtherUploads(CtfyModel):
1894    """The fold panel on one upload's page (#3110, phase 1)."""
1895
1896    #: The upload whose page this is.
1897    challenge_id: str
1898    #: The upload it is merged into now; ``""`` — its own challenge.
1899    merged_into: str = ""
1900    #: Of this upload and the others, the one approved and smoked clean
1901    #: (`core.authored_merge.suggested_survivor`); ``""`` — none is.
1902    suggested_survivor: str = ""
1903    uploads: list[AuthoredOtherUpload] = Field(default_factory=list)

The fold panel on one upload's page (#3110, phase 1).

challenge_id: str = PydanticUndefined
merged_into: str = ''
suggested_survivor: str = ''
uploads: list[AuthoredOtherUpload] = PydanticUndefined
class AuthoredPointsAtStake(ctfy.core.models.CtfyModel):
1852class AuthoredPointsAtStake(CtfyModel):
1853    """What removing an upload would decide about its points (#2991): the
1854    ranked solves and the teams holding them on the boards the act leaves,
1855    and the confirmation's effects for keeping and for removing them —
1856    the server's sentences, so the dialog never claims what will not be."""
1857
1858    action: AuthoredFlowAction
1859    solves: int = 0
1860    teams: int = 0
1861    kept: list[AuthoredActionEffectInfo] = Field(default_factory=list)
1862    removed: list[AuthoredActionEffectInfo] = Field(default_factory=list)

What removing an upload would decide about its points (#2991): the ranked solves and the teams holding them on the boards the act leaves, and the confirmation's effects for keeping and for removing them — the server's sentences, so the dialog never claims what will not be.

action: Literal['start_smoke', 'smoke_passes', 'smoke_fails', 'smoke_errors', 'tree_refused', 'smoke_retries', 'recheck_fails', 'recheck_passes', 'reupload', 'resmoke', 'approve', 'reject', 'reopen_review', 'publish', 'unpublish', 'republish', 'catalog_release', 'catalog_unrelease', 'retire', 'unretire', 'delete', 'curate', 'uncurate', 'submit_revision', 'withdraw_revision', 'approve_revision', 'reject_revision', 'apply_revision'] = PydanticUndefined
solves: int = 0
teams: int = 0
kept: list[AuthoredActionEffectInfo] = PydanticUndefined
removed: list[AuthoredActionEffectInfo] = PydanticUndefined
class AuthoredProbeCell(ctfy.core.models.CtfyModel):
586class AuthoredProbeCell(CtfyModel):
587    """The author listing's AI column for one upload (#3093).
588
589    The row's field is ``None`` where the event keeps the pre-check from its
590    authors (no column at all); this cell with ``latest = None`` is *shown
591    here, nothing has run yet*.
592    """
593
594    latest: ProbeBrief | None = None

The author listing's AI column for one upload (#3093).

The row's field is None where the event keeps the pre-check from its authors (no column at all); this cell with latest = None is shown here, nothing has run yet.

latest: ProbeBrief | None = None
class AuthoredQualityFlag(ctfy.core.models.CtfyModel):
125class AuthoredQualityFlag(CtfyModel):
126    """Players' reports on an upload that reached the threshold (plan P2.6).
127
128    One per report kind — ``buggy``, ``infra_broken`` or ``guessy`` — with
129    how many different people gave it, counted against the current
130    version of the tree.
131    """
132
133    reaction: Reaction
134    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 AuthoredQueueCounts(ctfy.core.models.CtfyModel):
1255class AuthoredQueueCounts(CtfyModel):
1256    """How many uploads each review tab holds, over the whole set (#2037 B1).
1257
1258    Counted in SQL over the same filters the tabs apply, in one request —
1259    never by fetching a page per tab. ``waiting`` is the work a reviewer
1260    has to do (new uploads plus proposed revisions); ``oldest_waiting_at``
1261    is when the longest-waiting of them was submitted, ``None`` with
1262    nothing waiting.
1263    """
1264
1265    new: int = 0
1266    revision: int = 0
1267    exec_unreviewed: int = 0
1268    uncurated: int = 0
1269    smoke_failed: int = 0
1270    #: Held by the safety check for a person (#3211): the upload's own
1271    #: outcome or its proposal's is ``held_malice``.
1272    held_malice: int = 0
1273    #: #3266: approved uploads whose newest re-check has a finding nobody
1274    #: released — 「已通过 · 重新预检有发现」.
1275    rerun_findings: int = 0
1276    waiting: int = 0
1277    oldest_waiting_at: AwareDatetime | None = None

How many uploads each review tab holds, over the whole set (#2037 B1).

Counted in SQL over the same filters the tabs apply, in one request — never by fetching a page per tab. waiting is the work a reviewer has to do (new uploads plus proposed revisions); oldest_waiting_at is when the longest-waiting of them was submitted, None with nothing waiting.

new: int = 0
revision: int = 0
exec_unreviewed: int = 0
uncurated: int = 0
smoke_failed: int = 0
held_malice: int = 0
rerun_findings: int = 0
waiting: int = 0
oldest_waiting_at: pydantic.types.AwareDatetime | None = None
class AuthoredReceptionRow(ctfy.core.models.CtfyModel):
2559class AuthoredReceptionRow(CtfyModel):
2560    """How the field fared against one challenge its author wrote.
2561
2562    ⚠️ **The counts are the rate's own inputs, not a reading of it.**
2563    `PeerAuthoringContext` carries ``solved`` and ``population`` beside
2564    ``rates`` precisely so this row does not have to recover a headcount
2565    from a float — ``round(rate * field_size)`` is a second spelling of
2566    the division that produced it, and the two disagree the moment
2567    either rounds.
2568    """
2569
2570    challenge_id: str
2571    #: The catalog's name for it, or ``""`` when the spec has gone —
2572    #: never the id twice, which reads as a name.
2573    name: str = ""
2574    state: AuthoredReceptionState = "not_curated"
2575    #: How many entrants of the author's own division fully solved it.
2576    solvers: int = 0
2577    #: The denominator: that division's size minus the author, who
2578    #: cannot solve their own upload. ``0`` when there is no rate.
2579    field_size: int = 0
2580    #: ``solvers / field_size``, or ``None`` when ``state`` is not
2581    #: ``priced``. ⚠️ Never ``0.0`` for an unpriced row: zero is the
2582    #: floor of the curve and a real verdict ("nobody could solve it"),
2583    #: which is exactly the sentence an uncurated challenge has not
2584    #: earned.
2585    solve_rate: float | None = None
2586    #: What the curve pays for that rate, in ``[floor, 1]`` — the
2587    #: multiplier the weight is applied to. ``None`` with ``solve_rate``.
2588    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 AuthoredRepublishRequest(ctfy.core.models.CtfyModel):
1865class AuthoredRepublishRequest(CtfyModel):
1866    """Restoring an upload an organiser took down (#1965 H). ``reason`` is
1867    optional — overruling a take-down is recorded either way."""
1868
1869    reason: str = Field("", max_length=2000)

Restoring an upload an organiser took down (#1965 H). reason is optional — overruling a take-down is recorded either way.

reason: str = ''
class AuthoredRevisionDiff(ctfy.core.models.CtfyModel):
1965class AuthoredRevisionDiff(CtfyModel):
1966    """A proposed revision against the live tree — what a reviewer reads.
1967
1968    ⚠️ **The diff, because the reviewer has read the live version.** A
1969    second review costs the size of the change only if the change is what
1970    they are shown; unchanged files are counted, not listed, and remain
1971    readable through the upload's own file route.
1972    """
1973
1974    revision: AdminAuthoredRevisionRow
1975    changes: list[AuthoredRevisionFileChange] = Field(default_factory=list)
1976    unchanged: int = 0
1977    #: Question ids the live tree serves and the revision would not. Always
1978    #: empty for a stored proposal — the door refuses one that drops any —
1979    #: and asked again at apply.
1980    dropped_questions: list[str] = Field(default_factory=list)
1981    #: The revision's whole tree, with where each file runs (plan P2.3).
1982    files: list[AuthoredChallengeFileEntry] = Field(default_factory=list)
1983    #: Whether applying asks the platform admin to confirm they read the
1984    #: files marked ``executes``: the upload is published and the revision
1985    #: has an execution surface. An unpublished upload's review happens at
1986    #: its publish instead.
1987    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):
1948class AuthoredRevisionFileChange(CtfyModel):
1949    """One path a revision changes (plan P3.1, `ctfy.core.authored_revisions`)."""
1950
1951    path: str
1952    change: FileChangeKind
1953    #: Decoded sizes; ``0`` on the side where the file does not exist.
1954    size_before: int = 0
1955    size_after: int = 0
1956    #: A unified diff from the live file to the revision's, or ``""`` when
1957    #: ``omitted`` says why not.
1958    diff: str = ""
1959    #: ``binary``, ``too_large`` or ``over_budget`` — ⚠️ never ``""`` beside
1960    #: an empty diff of a changed file, which would read as *nothing to
1961    #: see* about a file the reviewer was simply not shown.
1962    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):
315class AuthoredRevisionInfo(AuthoredRevisionRow):
316    """A revision plus the paths in it — what proposing one answers with,
317    where the tree is already in hand (`AuthoredChallengeInfo`'s rule)."""
318
319    files: list[str] = Field(default_factory=list)
320    #: The smoke run of this new version. Under automatic review (#1965 C)
321    #: it decides the revision — charged to the team's window and ``queued``
322    #: or ``waiting_for_node``. Under people review (#3050) it is the first
323    #: upload's courtesy run, under the same rules and the person's budget.
324    #: ``None`` is *not reported* (an older server, or a queue failure).
325    auto_smoke: AuthoredAutoSmoke | None = None

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
auto_smoke: AuthoredAutoSmoke | None = None
class AuthoredRevisionRow(ctfy.core.models.CtfyModel):
273class AuthoredRevisionRow(CtfyModel):
274    """A revision of a reviewed upload, as its author reads it (plan P3.1).
275
276    ``docs/community-authoring-plan.md`` P3.1: one of the upload's recorded
277    authors proposes a new tree for the same id; a reviewer rules on it as
278    a diff against the live version; a platform admin applies it.
279
280    ⚠️ **No operator ids**, `AuthoredChallengeRow`'s rule: who ruled is the
281    admin queue's business, and the author reads the verdict and the note.
282    """
283
284    id: str
285    #: The upload it revises.
286    authored_challenge_id: str
287    #: Who proposed it — one of the upload's recorded authors.
288    user_id: str
289    #: What to call them, composed server-side for the audience reading it
290    #: (`AuthoredChallengeRow.author_display_name`'s rule); ``""`` is *no
291    #: name to show*.
292    author_display_name: str = ""
293    #: The revision's own ``metadata.yaml`` ``name:``; the upload takes it
294    #: when the revision is applied.
295    title: str = ""
296    submitted_at: AwareDatetime | None = None
297    file_count: int = 0
298    total_bytes: int = 0
299    review_status: ReviewStatus = "pending"
300    reviewed_at: AwareDatetime | None = None
301    #: Why it was turned down, or what the reviewer said approving it.
302    review_note: str = ""
303    #: A platform administrator proposed it on the author's behalf (#3002).
304    revised_by_operator: bool = False
305    #: When a platform admin put it live. ``None`` is *a proposal* — the
306    #: upload still serves its own tree.
307    applied_at: AwareDatetime | None = None
308    #: Under automatic review (#1965 C3): ``held_smoke_unavailable`` when the
309    #: platform could not smoke it after every retry and left it to the
310    #: organisers — a fault of the platform's, never a verdict on the tree.
311    #: ``""`` is *nothing decided this way*.
312    automatic_outcome: AutomaticOutcomeState = ""

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 = ''
revised_by_operator: bool = False
applied_at: pydantic.types.AwareDatetime | None = None
automatic_outcome: Union[Literal[''], Literal['approved', 'rejected_unsolved', 'rejected_estate_failed', 'rejected_malformed', 'rejected_duplicate', 'rejected_injection', 'held_answers_not_minted', 'held_no_dedicated_pool', 'held_not_publishable', 'held_not_curatable', 'held_smoke_unavailable', 'held_malice']] = ''
class AuthoredRunningCompetition(ctfy.core.models.CtfyModel):
2261class AuthoredRunningCompetition(CtfyModel):
2262    """A running competition that holds an upload — what an
2263    ``after_competitions`` switch waits for (#2037)."""
2264
2265    id: str
2266    title: str = ""
2267    #: The title per locale when this row is a competition (#2230); a
2268    #: browser renders this, ``title`` is its base-locale reading.
2269    title_i18n: LocalizedText = Field(default_factory=dict)
2270    #: ``None`` is a competition that never ends, which such a switch would
2271    #: wait for for ever — so it is refused by code.
2272    ends_at: AwareDatetime | None = None

A running competition that holds an upload — what an after_competitions switch waits for (#2037).

id: str = PydanticUndefined
title: str = ''
title_i18n: dict[str, str] = PydanticUndefined
ends_at: pydantic.types.AwareDatetime | None = None
class AuthoredSameTitle(ctfy.core.models.CtfyModel):
1037class AuthoredSameTitle(CtfyModel):
1038    """One of the author's own uploads in this competition under the same
1039    title as the tree in hand (#3109) — the duplicate warning.
1040
1041    A re-upload under a *new* slug mints a second challenge, so this names the
1042    one it may have meant to be a new version of: re-upload with ``slug`` (or
1043    ``replaces: id``) to make it one. ``review_status`` says which door that
1044    is — a pending or rejected one is replaced by the upload door, an approved
1045    one takes a revision.
1046    """
1047
1048    id: str
1049    slug: str
1050    title: str
1051    review_status: ReviewStatus

One of the author's own uploads in this competition under the same title as the tree in hand (#3109) — the duplicate warning.

A re-upload under a new slug mints a second challenge, so this names the one it may have meant to be a new version of: re-upload with slug (or replaces: id) to make it one. review_status says which door that is — a pending or rejected one is replaced by the upload door, an approved one takes a revision.

id: str = PydanticUndefined
slug: str = PydanticUndefined
title: str = PydanticUndefined
review_status: Literal['pending', 'approved', 'rejected'] = PydanticUndefined
class AuthoredSmokeLine(ctfy.core.models.CtfyModel):
2425class AuthoredSmokeLine(CtfyModel):
2426    """One printed line. ``n`` is its 1-based place in its step; a jump in
2427    the numbers is lines the bound dropped."""
2428
2429    n: int
2430    at: AwareDatetime
2431    text: str

One printed line. n is its 1-based place in its step; a jump in the numbers is lines the bound dropped.

n: int = PydanticUndefined
at: pydantic.types.AwareDatetime = PydanticUndefined
text: str = PydanticUndefined
class AuthoredSmokeLive(ctfy.core.models.CtfyModel):
2410class AuthoredSmokeLive(CtfyModel):
2411    """The smoke run of an upload that is queued or running now (#2145).
2412
2413    ``none`` is a fact too — nothing is in flight — and the page then shows
2414    the stored runs only. ``steps`` are the ones that have begun, in order.
2415    """
2416
2417    state: Literal["none", "queued", "running"] = "none"
2418    run_id: str = ""
2419    kind: SmokeLogKind | Literal[""] = ""
2420    timing: SmokeRunTiming | None = None
2421    queue: SmokeQueueInfo | None = None
2422    steps: list[AuthoredSmokeLiveStep] = Field(default_factory=list)

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

none is a fact too — nothing is in flight — and the page then shows the stored runs only. steps are the ones that have begun, in order.

state: Literal['none', 'queued', 'running'] = 'none'
run_id: str = ''
kind: Union[Literal['upload', 'operator', 'recheck', 'revision'], Literal['']] = ''
timing: SmokeRunTiming | None = None
queue: SmokeQueueInfo | None = None
steps: list[AuthoredSmokeLiveStep] = PydanticUndefined
class AuthoredSmokeLiveStep(ctfy.server.models.AuthoredSmokeStepLog):
2402class AuthoredSmokeLiveStep(AuthoredSmokeStepLog):
2403    """One step of a run in flight."""
2404
2405    status: LiveStatus = "running"
2406    started_at: AwareDatetime | None = None
2407    ended_at: AwareDatetime | None = None

One step of a run in flight.

status: Literal['running', 'ok', 'failed', 'timed_out', 'skipped'] = 'running'
started_at: pydantic.types.AwareDatetime | None = None
ended_at: pydantic.types.AwareDatetime | None = None
class AuthoredSmokeLog(ctfy.server.models.AuthoredSmokeLogSummary):
2341class AuthoredSmokeLog(AuthoredSmokeLogSummary):
2342    """One smoke run of an upload, with its steps (#2024).
2343
2344    Each step's ``output`` is the tail of what the author's own build,
2345    container or exploit printed — terminal escapes stripped, bounded, and
2346    scrubbed of the worker's addresses, hostnames, paths and secrets. It is
2347    arbitrary text the author wrote: render it as text, never as markup.
2348    """
2349
2350    steps: list[SmokeLogStep] = Field(default_factory=list)
2351    #: When the run waited, started and ended (#2145); ``None`` for a run
2352    #: whose task row is gone.
2353    timing: SmokeRunTiming | None = None
2354    #: The steps whose **full** output was kept (#2145), read through the
2355    #: lines route. A stored step with no entry here has only the tail in
2356    #: its ``output`` — an older node, or a run nobody watched.
2357    step_logs: list[AuthoredSmokeStepLog] = Field(default_factory=list)

One smoke run of an upload, with its steps (#2024).

Each step's output is the tail of what the author's own build, container or exploit printed — terminal escapes stripped, bounded, and scrubbed of the worker's addresses, hostnames, paths and secrets. It is arbitrary text the author wrote: render it as text, never as markup.

steps: list[ctfy.core.smoke_log.SmokeLogStep] = PydanticUndefined
timing: SmokeRunTiming | None = None
step_logs: list[AuthoredSmokeStepLog] = PydanticUndefined
class AuthoredSmokeLogSummary(ctfy.core.models.CtfyModel):
2015class AuthoredSmokeLogSummary(CtfyModel):
2016    """One smoke run of an upload, as a row in its list of runs (#2024).
2017
2018    ``status`` says what the log holds — and, where it holds nothing, why:
2019    ``stored`` / ``truncated`` are steps; ``not_reported`` is a node older
2020    than this record (a verdict and nothing else); ``platform_fault`` is a
2021    run the platform could not carry out, ``problem`` saying why. A
2022    ``problem`` is always the platform's, never the author's.
2023    """
2024
2025    id: str
2026    authored_challenge_id: str
2027    #: The smoke task that produced the run (#2145) — the name its full
2028    #: output lines are read by, and ``""`` for a run that has none.
2029    run_id: str = ""
2030    #: ``""`` is the upload's own tree; otherwise the revision the run decided.
2031    revision_id: str = ""
2032    #: Which version of the tree the run was of.
2033    subject_submitted_at: AwareDatetime | None = None
2034    #: Whether that version is the upload's live tree today. ⚠️ ``False`` on
2035    #: a run of an earlier upload under the same id — its steps are about
2036    #: bytes that are no longer the challenge.
2037    current: bool = False
2038    kind: SmokeLogKind = "upload"
2039    #: The verdict the run reached, ``""`` when it reached none.
2040    verdict: AuthoredSmokeState = ""
2041    status: SmokeLogStatus = "not_reported"
2042    problem: SmokeLogProblem = ""
2043    #: How many times the estate was started (a failed start is retried
2044    #: once); the steps are the last attempt's.
2045    attempts: int = 0
2046    step_count: int = 0
2047    #: Steps that ``failed`` or ``timed_out`` — where to start reading.
2048    failed_step_count: int = 0
2049    recorded_at: AwareDatetime | None = None
2050    #: The stored version the run was of (#2037), ``""`` when it could not
2051    #: be told — `AuthoredVersionRow.id`.
2052    version_id: str = ""
2053    #: #2873: whether the run's ``RUN`` steps reached the network —
2054    #: ``offline`` makes a failed ``pip`` / ``apt`` / ``npm`` read as "needs
2055    #: the network". ``""`` is not known (an older node, a run that built
2056    #: nothing).
2057    build_network: SmokeBuildNetwork = ""

One smoke run of an upload, as a row in its list of runs (#2024).

status says what the log holds — and, where it holds nothing, why: stored / truncated are steps; not_reported is a node older than this record (a verdict and nothing else); platform_fault is a run the platform could not carry out, problem saying why. A problem is always the platform's, never the author's.

id: str = PydanticUndefined
authored_challenge_id: str = PydanticUndefined
run_id: str = ''
revision_id: str = ''
subject_submitted_at: pydantic.types.AwareDatetime | None = None
current: bool = False
kind: Literal['upload', 'operator', 'recheck', 'revision'] = 'upload'
verdict: Union[Literal[''], Literal['solved', 'unsolved', 'estate_failed', 'malformed', 'errored']] = ''
status: Literal['stored', 'truncated', 'not_reported', 'platform_fault'] = 'not_reported'
problem: Literal['', 'pool_unavailable', 'pool_busy', 'push_refused', 'node_unreachable', 'not_on_node', 'offline_unenforceable', 'session_unavailable', 'runner_image_unusable', 'build_unbudgeted', 'fence_unavailable', 'helper_images_missing', 'tunnel_unavailable', 'entry_unresolved', 'side_b_unavailable', 'side_b_failed', 'side_b_no_result', 'entrance_unreachable', 'smoke_unrouted', 'timed_out', 'node_error', 'platform_error', 'server_restarted'] = ''
attempts: int = 0
step_count: int = 0
failed_step_count: int = 0
recorded_at: pydantic.types.AwareDatetime | None = None
version_id: str = ''
build_network: Literal['', 'online', 'offline'] = ''
class AuthoredSmokeStepLines(ctfy.core.models.CtfyModel):
2434class AuthoredSmokeStepLines(CtfyModel):
2435    """A window of one step's full output (#2145).
2436
2437    Plain text the author's own process printed, scrubbed on the worker:
2438    render it as text, never as markup, and do not link anything in it.
2439    ``has_more`` is whether the log holds more in the direction asked (older
2440    for ``before``, newer for ``after``).
2441    """
2442
2443    key: str
2444    total: int = 0
2445    lines: list[AuthoredSmokeLine] = Field(default_factory=list)
2446    has_more: bool = False
2447    #: Whether the step is still running, so the reader knows to keep asking.
2448    running: bool = False

A window of one step's full output (#2145).

Plain text the author's own process printed, scrubbed on the worker: render it as text, never as markup, and do not link anything in it. has_more is whether the log holds more in the direction asked (older for before, newer for after).

key: str = PydanticUndefined
total: int = 0
lines: list[AuthoredSmokeLine] = PydanticUndefined
has_more: bool = False
running: bool = False
class AuthoredSmokeStepLog(ctfy.core.models.CtfyModel):
2390class AuthoredSmokeStepLog(CtfyModel):
2391    """One step whose output lines were kept (#2145)."""
2392
2393    key: str
2394    kind: SmokeStepKind
2395    service: str = ""
2396    question_id: str = ""
2397    #: Lines the step printed, and how many of them the bound dropped.
2398    lines: int = 0
2399    elided: int = 0

One step whose output lines were kept (#2145).

key: str = PydanticUndefined
kind: Literal['build', 'up', 'health', 'reach', 'exploit', 'grade'] = PydanticUndefined
service: str = ''
question_id: str = ''
lines: int = 0
elided: int = 0
class AuthoredStandardCheck(ctfy.core.models.CtfyModel):
1241class AuthoredStandardCheck(CtfyModel):
1242    """The CTFY-0002 audit's verdict on one upload, as a queue row carries it.
1243
1244    Read from a per-process cache the listing warms in the background —
1245    see `authored_standard_check`. ``errors`` / ``warnings`` are the
1246    audit's own totals, meaningful only when ``state`` is not ``unchecked``
1247    or ``unavailable``.
1248    """
1249
1250    state: StandardCheckState = "unchecked"
1251    errors: int = 0
1252    warnings: int = 0

The CTFY-0002 audit's verdict on one upload, as a queue row carries it.

Read from a per-process cache the listing warms in the background — see authored_standard_check. errors / warnings are the audit's own totals, meaningful only when state is not unchecked or unavailable.

state: Literal['unchecked', 'clean', 'advice', 'errors', 'unavailable'] = 'unchecked'
errors: int = 0
warnings: int = 0
class AuthoredStrayRow(ctfy.core.models.CtfyModel):
396class AuthoredStrayRow(CtfyModel):
397    """One stored challenge id a competition may not hold (#2553 G2) — an
398    upload of an invitation-only event sitting in another event's list or map.
399    What the strip removes (`ctfy.server.authored_strays.strip`): listed by
400    ``GET /admin/authored-challenges/strays``, removed by ``POST
401    …/strays/strip``; before an upgrade the offline ``ctfy-admin admin
402    authored strays --db`` lists what migration ``0248`` will remove."""
403
404    competition_id: str
405    competition_title: str = ""
406    competition_title_i18n: dict[str, str] = Field(default_factory=dict)
407    competition_access: str = ""
408    #: Which stored field holds it: a list (``challenge_ids``, ``pinned_…``,
409    #: ``preview_…``) or a per-challenge map (``challenge_schedule``, …).
410    field: str
411    challenge_id: str
412    home_competition_id: str
413    home_title: str = ""

One stored challenge id a competition may not hold (#2553 G2) — an upload of an invitation-only event sitting in another event's list or map. What the strip removes (ctfy.server.authored_strays.strip): listed by GET /admin/authored-challenges/strays, removed by POST …/strays/strip; before an upgrade the offline ctfy-admin admin authored strays --db lists what migration 0248 will remove.

competition_id: str = PydanticUndefined
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
competition_access: str = ''
field: str = PydanticUndefined
challenge_id: str = PydanticUndefined
home_competition_id: str = PydanticUndefined
home_title: str = ''
class AuthoredStripResult(ctfy.core.models.CtfyModel):
416class AuthoredStripResult(CtfyModel):
417    """``POST /admin/authored-challenges/strays/strip`` (#2553): what the
418    strip found and, with ``apply``, removed — each removal recorded with the
419    platform as actor and its solves' points kept."""
420
421    applied: bool
422    rows: list[AuthoredStrayRow] = Field(default_factory=list)
423    #: Running boxes of a removed id reclaimed in the competition it left.
424    instances_stopped: int = 0

POST /admin/authored-challenges/strays/strip (#2553): what the strip found and, with apply, removed — each removal recorded with the platform as actor and its solves' points kept.

applied: bool = PydanticUndefined
rows: list[AuthoredStrayRow] = PydanticUndefined
instances_stopped: int = 0
class AuthoredUnpublishRequest(ctfy.core.models.CtfyModel):
1835class AuthoredUnpublishRequest(CtfyModel):
1836    """Taking a published upload down (#1965 H). ``reason`` is required —
1837    blank is refused by code (``unpublish_needs_reason``), never stored.
1838
1839    ⚠️ ``stop_instances`` defaults **True** (#2037): taking an upload
1840    offline also reclaims its running boxes, unless the caller explicitly
1841    opts out. The boxes are stopped through the normal stop path and the
1842    stop is never charged against a team's rebuild quota."""
1843
1844    reason: str = Field("", max_length=2000)
1845    stop_instances: bool = True
1846    #: What happens to the points already scored on it (#2991): the person
1847    #: taking it down chooses. Absent (``None``) — nobody asked — keeps them,
1848    #: the take-down as it always was.
1849    scores: AuthoredScoresChoice | None = None

Taking a published upload down (#1965 H). reason is required — blank is refused by code (unpublish_needs_reason), never stored.

⚠️ stop_instances defaults True (#2037): taking an upload offline also reclaims its running boxes, unless the caller explicitly opts out. The boxes are stopped through the normal stop path and the stop is never charged against a team's rebuild quota.

reason: str = ''
stop_instances: bool = True
scores: Optional[Literal['kept', 'removed']] = None
class AuthoredUploadAccepted(ctfy.core.models.CtfyModel):
1023class AuthoredUploadAccepted(CtfyModel):
1024    """The archive arrived; the platform is still checking and storing it.
1025
1026    Poll ``GET /competitions/{competition_id}/authored-uploads/{id}`` until it
1027    stops answering 202: it then answers exactly what the upload would have —
1028    the stored row, or the refusal with its code and reasons.
1029    """
1030
1031    id: str
1032    #: Always ``True``: what a client tells this answer from the door's own by.
1033    processing: Literal[True] = True
1034    retry_after_s: int = 3

The archive arrived; the platform is still checking and storing it.

Poll GET /competitions/{competition_id}/authored-uploads/{id} until it stops answering 202: it then answers exactly what the upload would have — the stored row, or the refusal with its code and reasons.

id: str = PydanticUndefined
processing: Literal[True] = True
retry_after_s: int = 3
class AuthoredVersionDiff(ctfy.core.models.CtfyModel):
2308class AuthoredVersionDiff(CtfyModel):
2309    """What changed from one version of an upload to another (#2037) — the
2310    same answer for its author and its reviewers."""
2311
2312    authored_challenge_id: str
2313    from_version: AuthoredVersionRow
2314    to_version: AuthoredVersionRow
2315    changes: list[AuthoredVersionFileChange] = Field(default_factory=list)
2316    unchanged: int = 0
2317    #: Question ids *to* serves and *from* does not, and the reverse. A
2318    #: revision may only add (P3.1): a dropped id is what a reviewer must
2319    #: not miss.
2320    questions_added: list[str] = Field(default_factory=list)
2321    questions_dropped: list[str] = Field(default_factory=list)

What changed from one version of an upload to another (#2037) — the same answer for its author and its reviewers.

authored_challenge_id: str = PydanticUndefined
from_version: AuthoredVersionRow = PydanticUndefined
to_version: AuthoredVersionRow = PydanticUndefined
changes: list[AuthoredVersionFileChange] = PydanticUndefined
unchanged: int = 0
questions_added: list[str] = PydanticUndefined
questions_dropped: list[str] = PydanticUndefined
class AuthoredVersionFileChange(ctfy.core.models.CtfyModel):
2285class AuthoredVersionFileChange(CtfyModel):
2286    """One path that differs between two versions (#2037).
2287
2288    ``content_changed`` is ``False`` for a file whose bytes are the same and
2289    whose execute bit moved. A binary (``omitted == "binary"``) reports its
2290    sizes and its bit and nothing else.
2291    """
2292
2293    path: str
2294    change: FileChangeKind
2295    size_before: int = 0
2296    size_after: int = 0
2297    executable_before: bool = False
2298    executable_after: bool = False
2299    content_changed: bool = True
2300    lines_added: int = 0
2301    lines_removed: int = 0
2302    #: A unified diff, ``a/<path>`` → ``b/<path>``, or ``""`` when
2303    #: ``omitted`` says why not.
2304    diff: str = ""
2305    omitted: DiffOmission = ""

One path that differs between two versions (#2037).

content_changed is False for a file whose bytes are the same and whose execute bit moved. A binary (omitted == "binary") reports its sizes and its bit and nothing else.

path: str = PydanticUndefined
change: Literal['added', 'removed', 'modified'] = PydanticUndefined
size_before: int = 0
size_after: int = 0
executable_before: bool = False
executable_after: bool = False
content_changed: bool = True
lines_added: int = 0
lines_removed: int = 0
diff: str = ''
omitted: Literal['', 'binary', 'too_large', 'over_budget'] = ''
class AuthoredVersionMessage(ctfy.core.models.CtfyModel):
2179class AuthoredVersionMessage(CtfyModel):
2180    """One message in a version's thread (#2037). ``body`` is text the
2181    writer typed — render it as text, never as markup.
2182
2183    ``display_name`` is ``""`` for a reviewer who wrote as the event's review
2184    group (``as_review_group``), read by an author: the copy is then "the
2185    review group". ``mine`` is whether the reader wrote it.
2186    """
2187
2188    id: str
2189    version_id: str
2190    role: AuthoredMessageRole
2191    as_review_group: bool = False
2192    display_name: str = ""
2193    body: str = ""
2194    created_at: AwareDatetime | None = None
2195    mine: bool = False

One message in a version's thread (#2037). body is text the writer typed — render it as text, never as markup.

display_name is "" for a reviewer who wrote as the event's review group (as_review_group), read by an author: the copy is then "the review group". mine is whether the reader wrote it.

id: str = PydanticUndefined
version_id: str = PydanticUndefined
role: Literal['author', 'reviewer'] = PydanticUndefined
as_review_group: bool = False
display_name: str = ''
body: str = ''
created_at: pydantic.types.AwareDatetime | None = None
mine: bool = False
class AuthoredVersionMessageRequest(ctfy.core.models.CtfyModel):
2198class AuthoredVersionMessageRequest(CtfyModel):
2199    """A new message on a version's thread. ``as_review_group`` is a
2200    reviewer's choice to be named "the review group" to the author (the
2201    default); an author always writes as themselves."""
2202
2203    body: str = Field(min_length=1, max_length=4000)
2204    as_review_group: bool = True

A new message on a version's thread. as_review_group is a reviewer's choice to be named "the review group" to the author (the default); an author always writes as themselves.

body: str = PydanticUndefined
as_review_group: bool = True
class AuthoredVersionRow(ctfy.core.models.CtfyModel):
2067class AuthoredVersionRow(CtfyModel):
2068    """One stored version of an upload (#2037), as its author reads it.
2069
2070    ``status`` first: ``live`` is the tree a new box is built from — exactly
2071    one per upload — ``superseded`` one that was and was replaced,
2072    ``pending`` / ``approved`` / ``rejected`` a proposed revision by its
2073    verdict, ``withdrawn`` one its author took back and ``replaced`` one a
2074    later proposal replaced. ``tree_stored`` is ``False`` for a version the
2075    platform remembers without its files (stored before every version was
2076    kept); such a version can be neither downloaded nor compared.
2077    """
2078
2079    id: str
2080    authored_challenge_id: str
2081    #: v1, v2, … in the order this upload's versions were stored.
2082    number: int
2083    status: AuthoredVersionStatus
2084    source: AuthoredVersionSource = "upload"
2085    #: The revision a ``revision`` version was proposed as, ``""`` otherwise.
2086    revision_id: str = ""
2087    #: The author's own note on what changed — text, never markup.
2088    note: str = ""
2089    submitted_at: AwareDatetime | None = None
2090    #: When it became the upload's current tree, and when another replaced
2091    #: it; ``None`` where that never happened.
2092    effective_at: AwareDatetime | None = None
2093    superseded_at: AwareDatetime | None = None
2094    closed_at: AwareDatetime | None = None
2095    tree_revision: str = ""
2096    tree_stored: bool = True
2097    #: Whether the execute bits were recorded off the archive, or are
2098    #: inferred from content (an ELF, a ``#!``) because it predates them.
2099    exec_recorded: bool = False
2100    file_count: int = 0
2101    total_bytes: int = 0
2102    executable_count: int = 0
2103    #: Its review verdict: the upload's own while it is live, the
2104    #: proposal's while it waits, the one it had once it closed. ``None`` is
2105    #: *not known* — a version recorded without one.
2106    review_status: ReviewStatus | None = None
2107    reviewed_at: AwareDatetime | None = None
2108    review_note: str = ""
2109    smoke_verdict: AuthoredSmokeState = ""
2110    smoke_at: AwareDatetime | None = None
2111    #: The newest smoke log kept for it (`AuthoredSmokeLogSummary.id`).
2112    smoke_log_id: str = ""
2113    #: Who stored it, by display name; ``""`` when the account is gone.
2114    author_display_name: str = ""
2115    #: The tree's ``metadata.yaml`` ``name:`` as stored.
2116    title: str = ""
2117    #: For a ``rollback`` version — an admin making an earlier approved
2118    #: version live again — the number of the version whose tree it carries.
2119    rollback_of_number: int = 0
2120    #: Why the switch that made it live was made, when one was recorded and
2121    #: said why (a rollback always does).
2122    switch_reason: str = ""
2123    #: A platform administrator submitted this version on its author's
2124    #: behalf (#3002). The author stays the author; who it was is the
2125    #: reviewers' business (`AdminAuthoredVersionRow.revised_by_display_name`).
2126    revised_by_operator: bool = False
2127    #: What it changed against the tree it was built on. Empty is *not
2128    #: recorded* — only an administrator's revision records it.
2129    changed_files: list[AuthoredChangedFile] = Field(default_factory=list)

One stored version of an upload (#2037), as its author reads it.

status first: live is the tree a new box is built from — exactly one per upload — superseded one that was and was replaced, pending / approved / rejected a proposed revision by its verdict, withdrawn one its author took back and replaced one a later proposal replaced. tree_stored is False for a version the platform remembers without its files (stored before every version was kept); such a version can be neither downloaded nor compared.

id: str = PydanticUndefined
authored_challenge_id: str = PydanticUndefined
number: int = PydanticUndefined
status: Literal['live', 'superseded', 'pending', 'approved', 'rejected', 'withdrawn', 'replaced'] = PydanticUndefined
source: Literal['upload', 'revision', 'rollback'] = 'upload'
revision_id: str = ''
note: str = ''
submitted_at: pydantic.types.AwareDatetime | None = None
effective_at: pydantic.types.AwareDatetime | None = None
superseded_at: pydantic.types.AwareDatetime | None = None
closed_at: pydantic.types.AwareDatetime | None = None
tree_revision: str = ''
tree_stored: bool = True
exec_recorded: bool = False
file_count: int = 0
total_bytes: int = 0
executable_count: int = 0
review_status: Optional[Literal['pending', 'approved', 'rejected']] = None
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_log_id: str = ''
author_display_name: str = ''
title: str = ''
rollback_of_number: int = 0
switch_reason: str = ''
revised_by_operator: bool = False
changed_files: list[AuthoredChangedFile] = PydanticUndefined
class AuthoredVersionStorageInfo(ctfy.core.models.CtfyModel):
2324class AuthoredVersionStorageInfo(CtfyModel):
2325    """What the version store of uploaded challenges holds (#2037).
2326
2327    ``stored_bytes`` is what the content-addressed blobs take — each
2328    distinct file once — and ``logical_bytes`` what every version would take
2329    stored whole; the gap is what deduplication saves. Every version is kept
2330    (operator decision, #2037), so this only grows until uploads are deleted.
2331    """
2332
2333    uploads: int = 0
2334    versions: int = 0
2335    files: int = 0
2336    blobs: int = 0
2337    stored_bytes: int = 0
2338    logical_bytes: int = 0

What the version store of uploaded challenges holds (#2037).

stored_bytes is what the content-addressed blobs take — each distinct file once — and logical_bytes what every version would take stored whole; the gap is what deduplication saves. Every version is kept (operator decision, #2037), so this only grows until uploads are deleted.

uploads: int = 0
versions: int = 0
files: int = 0
blobs: int = 0
stored_bytes: int = 0
logical_bytes: int = 0
class AuthoredVersionSwitchInfo(ctfy.core.models.CtfyModel):
2232class AuthoredVersionSwitchInfo(CtfyModel):
2233    """One switch of an upload's live version, as the record keeps it (#2037)."""
2234
2235    id: str
2236    authored_challenge_id: str
2237    kind: AuthoredVersionSwitchKind
2238    version_id: str
2239    #: The target's number (v3), ``0`` if it is no longer recorded.
2240    version_number: int = 0
2241    when: AuthoredVersionSwitchWhen = "now"
2242    status: AuthoredVersionSwitchStatus = "scheduled"
2243    requested_by_display_name: str = ""
2244    requested_at: AwareDatetime | None = None
2245    reason: str = ""
2246    exec_surface_reviewed: bool = False
2247    decided_by_display_name: str = ""
2248    decided_at: AwareDatetime | None = None
2249    #: Why a scheduled switch was skipped, in English (``""`` otherwise).
2250    outcome: str = ""
2251    #: The version that became live — a rollback's is a new version carrying
2252    #: the target's tree.
2253    result_version_id: str = ""
2254    result_version_number: int = 0
2255    #: A scheduled switch the version lock (#2041) would refuse right now —
2256    #: its wait is that refusal deferred. ``False`` on an applied, skipped or
2257    #: cancelled switch, and whenever the lock does not hold.
2258    lock_holds_now: bool = False

One switch of an upload's live version, as the record keeps it (#2037).

id: str = PydanticUndefined
authored_challenge_id: str = PydanticUndefined
kind: Literal['apply', 'rollback'] = PydanticUndefined
version_id: str = PydanticUndefined
version_number: int = 0
when: Literal['now', 'after_competitions'] = 'now'
status: Literal['scheduled', 'applied', 'cancelled', 'skipped'] = 'scheduled'
requested_by_display_name: str = ''
requested_at: pydantic.types.AwareDatetime | None = None
reason: str = ''
exec_surface_reviewed: bool = False
decided_by_display_name: str = ''
decided_at: pydantic.types.AwareDatetime | None = None
outcome: str = ''
result_version_id: str = ''
result_version_number: int = 0
lock_holds_now: bool = False
class AuthoredVersionSwitchPlan(ctfy.core.models.CtfyModel):
2275class AuthoredVersionSwitchPlan(CtfyModel):
2276    """What switching an upload's live version would meet, and every switch
2277    made of it so far, newest first (#2037)."""
2278
2279    authored_challenge_id: str
2280    #: "X 场比赛正在进行": switching now changes what their new boxes run.
2281    running_competitions: list[AuthoredRunningCompetition] = Field(default_factory=list)
2282    switches: list[AuthoredVersionSwitchInfo] = Field(default_factory=list)

What switching an upload's live version would meet, and every switch made of it so far, newest first (#2037).

authored_challenge_id: str = PydanticUndefined
running_competitions: list[AuthoredRunningCompetition] = PydanticUndefined
switches: list[AuthoredVersionSwitchInfo] = PydanticUndefined
class AuthoredVersionSwitchRequest(ctfy.core.models.CtfyModel):
2207class AuthoredVersionSwitchRequest(CtfyModel):
2208    """Make a version live (#2037): an approved proposal is applied, an
2209    earlier approved version is rolled back to.
2210
2211    ``when`` is ``now`` — new boxes take it, running ones keep the tree they
2212    started with — or ``after_competitions``: applied automatically the first
2213    moment no running competition holds the challenge (cancellable; skipped
2214    and its admin told if the version may no longer be switched to by then).
2215    ``reason`` is required for a rollback. ``exec_surface_reviewed`` confirms
2216    you read what the tree runs, which a published upload's new tree needs.
2217
2218    ``force`` / ``force_reason`` push an immediate switch through a
2219    competition that locks its challenges' versions while in play (#2041):
2220    a platform administrator's act, and only with a reason
2221    (``version_lock_force_not_admin`` / ``version_lock_force_needs_reason``
2222    otherwise) — the reason is recorded with the switch.
2223    """
2224
2225    when: AuthoredVersionSwitchWhen = "now"
2226    reason: str = Field(default="", max_length=2000)
2227    exec_surface_reviewed: bool = False
2228    force: bool = False
2229    force_reason: str = Field(default="", max_length=2000)

Make a version live (#2037): an approved proposal is applied, an earlier approved version is rolled back to.

when is now — new boxes take it, running ones keep the tree they started with — or after_competitions: applied automatically the first moment no running competition holds the challenge (cancellable; skipped and its admin told if the version may no longer be switched to by then). reason is required for a rollback. exec_surface_reviewed confirms you read what the tree runs, which a published upload's new tree needs.

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

when: Literal['now', 'after_competitions'] = 'now'
reason: str = ''
exec_surface_reviewed: bool = False
force: bool = False
force_reason: str = ''
class AuthoringChecklistInfo(ctfy.core.models.CtfyModel):
724class AuthoringChecklistInfo(CtfyModel):
725    """``GET /admin/competitions/{id}/authoring-checklist`` (#1965 C3, decision 4).
726
727    What must be right before this event's uploads are opened to automatic
728    review: its dedicated pool, its authoring windows, its contributor
729    agreement, its smoke quota and its internet policy. ``ready`` is every
730    row green. ⚠️ **Advice, never a gate** — nothing refuses a save on it; the
731    one gate (an automatic event takes no upload without a dedicated pool) is
732    the upload door's, unchanged.
733    """
734
735    competition_id: str
736    authoring_review: AuthoringReview = "human"
737    ready: bool = False
738    rows: list[AuthoringChecklistRow] = Field(default_factory=list)

GET /admin/competitions/{id}/authoring-checklist (#1965 C3, decision 4).

What must be right before this event's uploads are opened to automatic review: its dedicated pool, its authoring windows, its contributor agreement, its smoke quota and its internet policy. ready is every row green. ⚠️ Advice, never a gate — nothing refuses a save on it; the one gate (an automatic event takes no upload without a dedicated pool) is the upload door's, unchanged.

competition_id: str = PydanticUndefined
authoring_review: Literal['human', 'automatic'] = 'human'
ready: bool = False
rows: list[AuthoringChecklistRow] = PydanticUndefined
class AuthoringChecklistRow(ctfy.core.models.CtfyModel):
710class AuthoringChecklistRow(CtfyModel):
711    """One row of the checklist before an event opens automatic review (#1965 C3).
712
713    ``ok`` false is red. ``reason`` says which state the setting is in, and
714    ``value`` is the setting itself where the row's sentence names it — the
715    quota, the policy, the next window's start, the agreement's version.
716    """
717
718    key: AuthoringChecklistItemKey
719    ok: bool
720    reason: AuthoringChecklistReason
721    value: str = ""

One row of the checklist before an event opens automatic review (#1965 C3).

ok false is red. reason says which state the setting is in, and value is the setting itself where the row's sentence names it — the quota, the policy, the next window's start, the agreement's version.

key: Literal['dedicated_pool', 'authoring_windows', 'agreement', 'smoke_quota', 'internet_policy', 'model_review', 'isolated_node'] = PydanticUndefined
ok: bool = PydanticUndefined
reason: Literal['pool_ready', 'pool_no_live_node', 'pool_not_declared', 'windows_open', 'windows_ahead', 'windows_unset', 'windows_over', 'agreement_set', 'agreement_unset', 'quota_set', 'internet_denied', 'internet_not_denied', 'model_review_ready', 'model_review_unavailable', 'isolated_ready', 'isolated_missing', 'isolated_not_needed'] = PydanticUndefined
value: str = ''
AuthoringDivision = typing.Union[typing.Literal[''], typing.Literal['human', 'agent']]
class AuthoringPauseInfo(ctfy.core.models.CtfyModel):
2679class AuthoringPauseInfo(CtfyModel):
2680    """Whether this caller's uploads to one event are paused, and by which rule (#1965 C3).
2681
2682    The page replaces its form while ``paused_until`` is ahead, exactly as
2683    the upload door refuses (429 ``authoring_cooldown``) — one function,
2684    ``authoring_cooldown.paused_until``, answers both.
2685
2686    ``scope`` is the rule in force here: ``platform`` (the person's
2687    rejections in every competition), ``competition`` (in this one only),
2688    ``off``. ``threshold`` / ``window_s`` / ``pause_s`` are that rule's
2689    numbers, ``0`` under ``off``.
2690    """
2691
2692    competition_id: str
2693    scope: AuthoringCooldownScope
2694    threshold: int = 0
2695    window_s: int = 0
2696    pause_s: int = 0
2697    #: When uploads here reopen. ``None`` is *not paused* — never "unknown":
2698    #: the route asked.
2699    paused_until: AwareDatetime | None = None

Whether this caller's uploads to one event are paused, and by which rule (#1965 C3).

The page replaces its form while paused_until is ahead, exactly as the upload door refuses (429 authoring_cooldown) — one function, authoring_cooldown.paused_until, answers both.

scope is the rule in force here: platform (the person's rejections in every competition), competition (in this one only), off. threshold / window_s / pause_s are that rule's numbers, 0 under off.

competition_id: str = PydanticUndefined
scope: Literal['platform', 'competition', 'off'] = PydanticUndefined
threshold: int = 0
window_s: int = 0
pause_s: int = 0
paused_until: pydantic.types.AwareDatetime | None = None
class AuthoringQuotaInfo(ctfy.core.models.CtfyModel):
2740class AuthoringQuotaInfo(CtfyModel):
2741    """The allowances one author is spending on one event (#2037 A1).
2742
2743    Read-only and charging nothing: every number is what the upload door
2744    itself would count (``pending_upload_cap``, the person's rolling-day
2745    ``read_person_smoke_budget``, or — under automatic review — the team's
2746    window quota), so the page's four quota cards cannot disagree with the
2747    refusal a submit would meet.
2748
2749    ⚠️ ``0`` in a cap or budget is **off / unlimited**, never "none left":
2750    ``pending_cap == 0`` is no limit and ``smoke_charged_to == "off"`` is no
2751    automatic smoking at all. The page reads ``smoke_charged_to`` first.
2752    """
2753
2754    competition_id: str
2755    #: Uploads of the caller's team awaiting review now, and the most one
2756    #: team may have. ``0`` cap = unlimited.
2757    pending_count: int = 0
2758    pending_cap: int = 0
2759    #: Whose allowance an upload's automatic smoke run is charged to:
2760    #: ``person`` (the platform's rolling day), ``team`` (the event's
2761    #: per-window quota under automatic review) or ``off`` (no automatic
2762    #: smoking here, nothing is charged).
2763    smoke_charged_to: Literal["person", "team", "off"] = "off"
2764    smoke_budget: int = 0
2765    smoke_used: int = 0
2766    smoke_remaining: int = 0
2767    #: When the oldest counted run leaves the window (the next free run);
2768    #: ``None`` while any is left or nothing is counted.
2769    smoke_available_at: AwareDatetime | None = None
2770    #: The team's upload rate limit (#2079); ``None`` = this event has none.
2771    upload_rate: AuthoringRateInfo | None = None
2772    #: The team's per-window quota of new uploads, read without charging it;
2773    #: ``None`` = no per-window limit here (or no window open now).
2774    upload_window: AuthoringWindowQuotaInfo | None = None
2775    #: How many models the AI pre-check sends each new version to, and the
2776    #: most seconds it may take per version (#3093) — the upload button's
2777    #: "what this will run". ``0`` where the event chose no model **or keeps
2778    #: the pre-check from its authors**: the count would tell an author the
2779    #: event checks uploads with AI, which the switch withholds (#3053 D1).
2780    ai_precheck_models: int = 0
2781    ai_precheck_seconds: int = 0

The allowances one author is spending on one event (#2037 A1).

Read-only and charging nothing: every number is what the upload door itself would count (pending_upload_cap, the person's rolling-day read_person_smoke_budget, or — under automatic review — the team's window quota), so the page's four quota cards cannot disagree with the refusal a submit would meet.

⚠️ 0 in a cap or budget is off / unlimited, never "none left": pending_cap == 0 is no limit and smoke_charged_to == "off" is no automatic smoking at all. The page reads smoke_charged_to first.

competition_id: str = PydanticUndefined
pending_count: int = 0
pending_cap: int = 0
smoke_charged_to: Literal['person', 'team', 'off'] = 'off'
smoke_budget: int = 0
smoke_used: int = 0
smoke_remaining: int = 0
smoke_available_at: pydantic.types.AwareDatetime | None = None
upload_rate: AuthoringRateInfo | None = None
upload_window: AuthoringWindowQuotaInfo | None = None
ai_precheck_models: int = 0
ai_precheck_seconds: int = 0
class AuthoringRateInfo(ctfy.core.models.CtfyModel):
982class AuthoringRateInfo(CtfyModel):
983    """Where a team stands against its event's upload rate limit (#2079).
984
985    Read-only and charging nothing. ``remaining`` stored uploads are left
986    in the rolling ``window_s``; ``frees_at`` is when the oldest counted
987    one leaves it (``None`` while nothing is counted) and ``next_allowed_at``
988    is set only while ``remaining == 0`` — when the team may upload again.
989    Only a stored upload or version counts: a refused one, a dry run and a
990    platform retry do not. ``None`` where this appears means the event has
991    **no limit** (``0`` on either number is off).
992    """
993
994    limit: int
995    window_s: int
996    used: int
997    remaining: int
998    frees_at: AwareDatetime | None = None
999    next_allowed_at: AwareDatetime | None = None

Where a team stands against its event's upload rate limit (#2079).

Read-only and charging nothing. remaining stored uploads are left in the rolling window_s; frees_at is when the oldest counted one leaves it (None while nothing is counted) and next_allowed_at is set only while remaining == 0 — when the team may upload again. Only a stored upload or version counts: a refused one, a dry run and a platform retry do not. None where this appears means the event has no limit (0 on either number is off).

limit: int = PydanticUndefined
window_s: int = PydanticUndefined
used: int = PydanticUndefined
remaining: int = PydanticUndefined
frees_at: pydantic.types.AwareDatetime | None = None
next_allowed_at: pydantic.types.AwareDatetime | None = None
class AuthoringReceptionInfo(ctfy.core.models.CtfyModel):
2702class AuthoringReceptionInfo(CtfyModel):
2703    """What this caller's team earned for the challenges it wrote.
2704
2705    ⚠️ **Composed from the board's own producer**, never from a second
2706    tally: the rows come out of `PeerAuthoringRule.prepare` run over the
2707    facts `board_facts` scopes, and ``authoring_score`` out of the same
2708    rule's ``score``. A page that summed the multipliers itself would be
2709    a second implementation of the rounding, free to disagree with the
2710    scoreboard on the next tab.
2711    """
2712
2713    #: Whether this competition scores authoring at all. ``False`` on
2714    #: every other rule, and the rows are then empty.
2715    scored: bool = False
2716    #: The division the rates above are against — a rate is per
2717    #: division, so the number is unreadable without it.
2718    division: AuthoringDivision = ""
2719    #: The competition's stored multiplier. ``0`` is the default and
2720    #: means authoring currently pays nothing, which is a real state
2721    #: rather than a missing one.
2722    authoring_weight: float = 0.0
2723    #: What the board credits this team for authoring, right now.
2724    #:
2725    #: ⚠️ Recomputed here rather than read off the cached board, so this
2726    #: page can be a moment fresher than the scoreboard's TTL. The two
2727    #: cannot *disagree* — same rule, same facts — they can only be at
2728    #: different instants, which every live number on the platform is.
2729    authoring_score: int = 0
2730    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):
2989class AuthoringReviewerInfo(CtfyModel):
2990    """One per-competition authoring-reviewer grant, for the super-admin
2991    card on the competition detail page.
2992
2993    ``docs/community-authoring-plan.md`` P2.3, decision 16: the holder
2994    rules on the content of this event's uploaded challenges and reaches
2995    nothing else — not the organiser's console, not the entrants'
2996    registration details. Its own row type rather than a third
2997    ``CompetitionAdminInfo.role``, because every reader of that table
2998    decides something this grant must not reach.
2999    """
3000
3001    user_id: str
3002    display_name: str = ""
3003    email: str = ""
3004    granted_by: str = ""
3005    #: What the super-admin who granted it is called — a user id on the
3006    #: wire carries its label beside it.
3007    granted_by_display_name: str = ""
3008    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):
859class AuthoringScaleInfo(CtfyModel):
860    """What a peer-authoring board's ``authoring_weight`` is worth.
861
862    ⚠️ **The reading is "how many solved challenges", not "what share of
863    the total", and the missing denominator is why.** A share needs to
864    know how many challenges a team writes and how many it solves; the
865    platform holds neither — ``max_pending_authored_challenges`` bounds a
866    review queue, not an event — so a percentage here could only come
867    from an assumption, and an assumption rendered as a figure is read as
868    a measurement.
869
870    ⚠️ **Composed here because the price is.** ``scoring_params`` crosses
871    as ``dict[str, Any]``, so a browser-side version would be a second
872    implementation of the point table and its decay — see
873    ``ctfy.server.authoring_scale``.
874    """
875
876    #: Whether this competition scores authoring at all — the producer's
877    #: own ``isinstance`` verdict on the **stored** rule, which it used
878    #: to compute and drop.
879    #:
880    #: ⚠️ **Every other field below is a default on a ``False``, and the
881    #: defaults are indistinguishable from real answers.** A rule that is
882    #: not ``peer_authoring`` returns a bare row, so ``challenges_priced``
883    #: reads ``0`` and ``equivalent_challenges`` reads ``None`` however
884    #: full the challenge set is — measured across four rules against a
885    #: set with a priceable challenge in it. A reader that branches on
886    #: those without asking this one first says *nothing in this event's
887    #: challenge set can be priced yet* about a set that is priced and
888    #: curated, so this is asked **before** them, never instead of them.
889    #:
890    #: ⚠️ ``False`` by default, `AuthoringReceptionInfo.scored`'s rule on
891    #: the same question: a producer that forgets it takes a real
892    #: reading away, which somebody reports, where the other direction
893    #: puts the false sentence back in silence.
894    scored: bool = False
895    #: The stored weight, echoed so a reader knows which number the rest
896    #: of this row is about. Zero for a competition that has not saved
897    #: the rule, which is the honest answer rather than a second state:
898    #: authoring scores nothing there.
899    weight: float = 0.0
900    #: How many of the competition's challenges could be priced.
901    challenges_priced: int = 0
902    #: The typical challenge's full-solve price. Shipped beside the ratio
903    #: because it is what makes the ratio checkable against the
904    #: organiser's own point table.
905    median_challenge_points: int = 0
906    #: ⚠️ ``None``, never ``0.0``, for a set nothing could be priced
907    #: from. Zero is a statement about the *weight* ("authoring is worth
908    #: nothing here"); an empty challenge set says nothing about the
909    #: weight at all, and the two get opposite advice.
910    equivalent_challenges: float | None = None
911    #: Whether the solving half decays. When it does, the solving side
912    #: only shrinks as the field grows, so the ratio is a **floor** — the
913    #: direction that matters, since understating it is what makes an
914    #: organiser pick a weight larger than they meant.
915    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 AuthoringSmokeLedgerInfo(ctfy.core.models.CtfyModel):
761class AuthoringSmokeLedgerInfo(CtfyModel):
762    """``GET /admin/competitions/{id}/authoring/smoke-runs`` (#1979 E3).
763
764    Every smoke run charged inside this competition, oldest first — what a
765    load rehearsal adds up into each team's quota spend, the platform's
766    retries and its refunds. ⚠️ **The whole set, unpaged**: a page would say
767    *not refunded* about the row after it, and the rows are bounded by the
768    event (teams × quota × windows, plus retries).
769    """
770
771    competition_id: str
772    rows: list[AuthoringSmokeRunRow] = Field(default_factory=list)

GET /admin/competitions/{id}/authoring/smoke-runs (#1979 E3).

Every smoke run charged inside this competition, oldest first — what a load rehearsal adds up into each team's quota spend, the platform's retries and its refunds. ⚠️ The whole set, unpaged: a page would say not refunded about the row after it, and the rows are bounded by the event (teams × quota × windows, plus retries).

competition_id: str = PydanticUndefined
rows: list[AuthoringSmokeRunRow] = PydanticUndefined
class AuthoringSmokeRunRow(ctfy.core.models.CtfyModel):
741class AuthoringSmokeRunRow(CtfyModel):
742    """One automatic smoke run on the ledger, as an organiser reads it (#1979 E3).
743
744    ``purpose`` is which allowance it was charged to — ``quota`` the team's
745    window, ``retry`` nobody's (the platform re-running a run that reached no
746    verdict), ``budget`` a person's platform day. ``refunded_at`` set means
747    the platform gave the charge back because the run reached no verdict
748    (#1965 C3); ``refund_reason`` is the node's or the task's error.
749    """
750
751    run_id: str
752    authored_challenge_id: str
753    team_id: str
754    user_id: str
755    purpose: AuthoredSmokeRunPurpose
756    requested_at: AwareDatetime | None = None
757    refunded_at: AwareDatetime | None = None
758    refund_reason: str = ""

One automatic smoke run on the ledger, as an organiser reads it (#1979 E3).

purpose is which allowance it was charged to — quota the team's window, retry nobody's (the platform re-running a run that reached no verdict), budget a person's platform day. refunded_at set means the platform gave the charge back because the run reached no verdict (#1965 C3); refund_reason is the node's or the task's error.

run_id: str = PydanticUndefined
authored_challenge_id: str = PydanticUndefined
team_id: str = PydanticUndefined
user_id: str = PydanticUndefined
purpose: Literal['budget', 'quota', 'retry'] = PydanticUndefined
requested_at: pydantic.types.AwareDatetime | None = None
refunded_at: pydantic.types.AwareDatetime | None = None
refund_reason: str = ''
class AuthoringTemplateFile(ctfy.core.models.CtfyModel):
1280class AuthoringTemplateFile(CtfyModel):
1281    """One file in a starter template, as the download would write it."""
1282
1283    #: Relative to the benchmark directory, so it reads the way the
1284    #: spec does (``services/app/main.go``) rather than carrying the
1285    #: archive's wrapping directory, which is an artifact of the
1286    #: transport and not something the author will ever see on disk.
1287    path: str
1288    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):
1291class AuthoringTemplateManifest(CtfyModel):
1292    """What ``GET /authoring/template`` would hand over, without handing it.
1293
1294    ⚠️ **The page downloaded a ``.tar.gz`` blind.** A picker with nine
1295    shapes behind it and no way to see inside one is a choice made from
1296    a label; the listing is what turns it into a choice made from the
1297    thing.
1298
1299    ⚠️ **Derived from the same `scaffold_authored`, never described.**
1300    A hand-written listing beside a live scaffolder is the second
1301    specification this feature already refused once — the reason the
1302    download route *builds* the tree per request rather than serving a
1303    checked-in archive — and it would go stale on whichever path nobody
1304    exercised. So this route builds the tree too and reads it back;
1305    `TestTheManifestIsTheArchive` drives both and compares the sets,
1306    because two producers of one fact is the whole hazard.
1307
1308    ⚠️ ``total_bytes`` is the **decoded** sum rather than the archive's
1309    size, so it is the same quantity `AuthoredChallengeInfo.total_bytes`
1310    reports on the upload that comes back. Two numbers for one fact is
1311    the recurring cost here; the one an author can compare is worth
1312    more than the one that measures the transfer.
1313    """
1314
1315    files: list[AuthoringTemplateFile] = Field(default_factory=list)
1316    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 AuthoringWindowInfo(ctfy.core.models.CtfyModel):
417class AuthoringWindowInfo(CtfyModel):
418    """One period in which a competition takes uploaded challenges, on
419    the wire. Either bound may be absent — unbounded on that side — which
420    is what a competition migrated from ``0120``'s single window can hold
421    (``CompetitionState.authoring_windows``). The admin routes refuse a
422    window with neither bound and any two that overlap.
423    """
424
425    starts_at: datetime | None = None
426    ends_at: datetime | None = None
427
428    # An admin form sends ``""`` for a cleared date input.
429    _norm_times = field_validator("starts_at", "ends_at", mode="before")(_blank_to_none)

One period in which a competition takes uploaded challenges, on the wire. Either bound may be absent — unbounded on that side — which is what a competition migrated from 0120's single window can hold (CompetitionState.authoring_windows). The admin routes refuse a window with neither bound and any two that overlap.

starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
class AuthoringWindowQuotaInfo(ctfy.core.models.CtfyModel):
1002class AuthoringWindowQuotaInfo(CtfyModel):
1003    """Where a team stands against its event's per-window upload quota.
1004
1005    Read-only and charging nothing. ``limit`` **new** uploads may be started
1006    in the window open now (``starts_at`` .. ``ends_at``, ``None`` = open on
1007    that side); ``used`` were, ``remaining`` are left. Revising an upload the
1008    team already holds is never counted, so ``remaining == 0`` still allows it.
1009    ``next_window_at`` is when the following window opens (``None`` = none
1010    follows), the earliest a team with ``remaining == 0`` may start another.
1011    ``None`` where this appears means the event sets **no** per-window limit
1012    (``0`` is unlimited on this axis).
1013    """
1014
1015    limit: int
1016    used: int
1017    remaining: int
1018    starts_at: AwareDatetime | None = None
1019    ends_at: AwareDatetime | None = None
1020    next_window_at: AwareDatetime | None = None

Where a team stands against its event's per-window upload quota.

Read-only and charging nothing. limit new uploads may be started in the window open now (starts_at .. ends_at, None = open on that side); used were, remaining are left. Revising an upload the team already holds is never counted, so remaining == 0 still allows it. next_window_at is when the following window opens (None = none follows), the earliest a team with remaining == 0 may start another. None where this appears means the event sets no per-window limit (0 is unlimited on this axis).

limit: int = PydanticUndefined
used: int = PydanticUndefined
remaining: int = PydanticUndefined
starts_at: pydantic.types.AwareDatetime | None = None
ends_at: pydantic.types.AwareDatetime | None = None
next_window_at: pydantic.types.AwareDatetime | None = None
class AwardBandInfo(ctfy.core.models.CtfyModel):
58class AwardBandInfo(CtfyModel):
59    """One prize tier: final ranks ``from_rank``..``to_rank``, inclusive."""
60
61    model_config = ConfigDict(extra="forbid")
62
63    name: LocalizedText = Field(default_factory=dict)
64    from_rank: int = Field(default=1, ge=1, le=100000)
65    to_rank: int = Field(default=1, ge=1, le=100000)
66
67    _v_name = field_validator("name", mode="before")(_band_name)

One prize tier: final ranks from_rank..to_rank, inclusive.

name: dict[str, str] = PydanticUndefined
from_rank: int = 1
to_rank: int = 1
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):
1630class AwdClockBrief(CtfyModel):
1631    """The compact round clock a list card / header progress bar draws.
1632
1633    ⚠️ **The same round arithmetic as ``AwdMatchStatus``, carried on the
1634    *list* row** so a card can draw "round N / M · this round ends in
1635    mm:ss" without a per-row ``/awd/match`` fetch — the N+1 shape
1636    ``awd_match_ids`` / ``team_counts`` already remove for this
1637    projection. It is a brief, not the full status: the budgets and the
1638    ids on ``AwdMatchStatus`` are redundant on a row that already names
1639    the competition, and a card never reads them.
1640
1641    ⚠️ **Never recomputed in the browser.** ``current_tick`` /
1642    ``tick_ends_at`` / ``ends_at`` come off ``ctfy.core.awd`` here,
1643    exactly as ``AwdMatchStatus`` documents, so the card, the header and
1644    the match page cannot disagree at a round boundary — the copy that
1645    re-derives ``starts_at + tick_seconds * n`` is the one that is wrong
1646    at the instant a room is looking at the clock.
1647
1648    ⚠️ **``None`` on ``CompetitionInfo`` means "not a match / not
1649    scheduled", not "round zero".** ``current_tick`` is ``None`` before
1650    the first round and after the last (``core.awd`` represents the gap
1651    as a real state), and ``last_tick_done`` is ``-1`` before anything
1652    settles — the same sentinels the full status carries.
1653    """
1654
1655    #: The match's kickoff — when its first round opens.
1656    #:
1657    # ⚠️ **The match's own ``starts_at``, never the competition's.** The two
1658    # are different instants on purpose (`core.awd.provisioning_window_s`
1659    # says why), and the gap is the ordinary case rather than an edge:
1660    # `admin competition create --awd-starts-at …` writes a competition
1661    # with no window of its own, so ``CompetitionInfo.starts_at`` is unset
1662    # and the competition reads as running from the moment it is created.
1663    # Without this field the brief could not say that nothing has started
1664    # — ``current_tick`` is ``None`` both before the first round and after
1665    # the last — and a header drew "round 0 of 12" under a 进行中 chip for
1666    # the whole wait before kickoff, with no countdown anywhere.
1667    #
1668    # ⚠️ ``None`` only where the whole brief is (an unscheduled match); a
1669    # brief exists only for a match that has a kickoff.
1670    starts_at: datetime | None = None
1671    #: The round length and count — the denominators the bar needs.
1672    tick_seconds: int = 0
1673    tick_count: int = 0
1674    #: The round in progress, or ``None`` before the first and after the
1675    #: last. Never stored; recomputed on every read.
1676    current_tick: int | None = None
1677    #: When the round in progress ends. ``None`` when none is.
1678    tick_ends_at: datetime | None = None
1679    #: When the last round closes — the match's own end. ``None`` when the
1680    #: match has no ``starts_at`` (unscheduled), never an epoch.
1681    ends_at: datetime | None = None
1682    #: The last round settled — the one a board is showing. ``-1`` means
1683    #: none has been, which is not round 0.
1684    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
class BatchAddMembersRequest(ctfy.core.models.CtfyModel):
728class BatchAddMembersRequest(CtfyModel):
729    """Body for ``POST /admin/competitions/{id}/members/batch`` (#2078).
730
731    ``text`` is what the organiser has — a pasted column or a CSV file's
732    contents — and the server splits it (see ``core.batch_members``): a
733    header row is optional, columns are ``email, team, captain``. The
734    length is capped by the route (coded ``too_many_rows``), not by a
735    ``max_length`` here, so an over-long paste answers with a code the
736    browser can translate rather than a validation envelope.
737    """
738
739    text: str = ""
740    # Classify every row against current state and write nothing. The
741    # preview table is this call; ``team_full`` is not predicted, since it
742    # depends on the writes before it.
743    dry_run: bool = False

Body for POST /admin/competitions/{id}/members/batch (#2078).

text is what the organiser has — a pasted column or a CSV file's contents — and the server splits it (see core.batch_members): a header row is optional, columns are email, team, captain. The length is capped by the route (coded too_many_rows), not by a max_length here, so an over-long paste answers with a code the browser can translate rather than a validation envelope.

text: str = ''
dry_run: bool = False
class BatchAddMembersResponse(ctfy.core.models.CtfyModel):
763class BatchAddMembersResponse(CtfyModel):
764    results: list[BatchMemberResultRow] = Field(default_factory=list)
765    dry_run: bool = False
766    # Per outcome, so a toast reads without walking the rows. Only outcomes
767    # that occurred are present.
768    counts: dict[str, int] = Field(default_factory=dict)

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.

results: list[BatchMemberResultRow] = PydanticUndefined
dry_run: bool = False
counts: dict[str, int] = PydanticUndefined
class BatchMemberResultRow(ctfy.core.models.CtfyModel):
746class BatchMemberResultRow(CtfyModel):
747    """What happened to one row, echoed back so 300 lines can be seen to
748    have been understood (the bulk invite's rule)."""
749
750    # 1-based among the data rows (header excluded).
751    row: int
752    email: str = ""
753    team_name: str = ""
754    outcome: BatchMemberOutcome
755    # The user's id and team when there is one; empty otherwise.
756    user_id: str = ""
757    team_id: str = ""
758    # For ``already_invited`` on a re-run: what happened when the address
759    # was proved and the person was placed (``""`` = not yet).
760    placement: InvitePlacementOrUnset = ""

What happened to one row, echoed back so 300 lines can be seen to have been understood (the bulk invite's rule).

row: int = PydanticUndefined
email: str = ''
team_name: str = ''
outcome: ctfy.core.batch_members.BatchMemberOutcome = PydanticUndefined
user_id: str = ''
team_id: str = ''
placement: Union[ctfy.core.enums.InvitePlacement, Literal['']] = ''
BigLimitOffsetPage = <class 'ctfy.server.models.pagination.LimitOffsetPageCustomized'>
BigLimitOffsetPageOf100 = <class 'ctfy.server.models.pagination.LimitOffsetPageCustomized'>
class BlueprintHostInfo(ctfy.core.models.CtfyModel):
66class BlueprintHostInfo(CtfyModel):
67    """One service in a range's estate, as an organiser sees it."""
68
69    name: str
70    segments: list[str] = Field(default_factory=list)
71    published: bool = False
72    #: Milestones whose value this service is handed.
73    milestones: list[str] = Field(default_factory=list)
74    #: Shares no network with anything published, so reaching it costs a
75    #: foothold. ⚠️ Defaults **False** — the claim a wrong value makes
76    #: matters here, and "reachable" is the one that merely costs a
77    #: second look, while a wrong "internal" tells an organiser a box is
78    #: protected when it is on the public segment.
79    behind_pivot: bool = False
80    #: ``estate`` | ``infrastructure``
81    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):
 84class BlueprintMilestoneInfo(CtfyModel):
 85    """One milestone, placed in the estate and in the chain."""
 86
 87    id: str
 88    #: ``dynamic`` | ``static`` | ``seeded`` | ``single_select`` |
 89    #: ``multi_select`` | ``checker`` | ``unknown``
 90    mode: MilestoneMode = MilestoneMode.UNKNOWN
 91    requires: list[str] = Field(default_factory=list)
 92    #: Services carrying the value — **plural**, because a capability
 93    #: with several producers means several services legitimately hold a
 94    #: valid answer and naming one would invent a fact.
 95    hosts: list[str] = Field(default_factory=list)
 96    #: Which score capturing it feeds: ``capability`` or ``reporting``.
 97    #:
 98    #: ⚠️ **On the organiser's blueprint and on no player surface**, and
 99    #: the split is deliberate rather than an oversight — see
100    #: ``BlueprintMilestone.scores``. Telling a player which milestones
101    #: are cul-de-sac evidence hands over the triage judgement the
102    #: dead-end tier exists to measure; telling an *organiser* is the
103    #: only way they can see the chain apart from the wrong turns on a
104    #: range they did not author.
105    scores: str = "capability"
106    #: 1 for a milestone with no prerequisite; 0 means the ``requires``
107    #: graph has a cycle through this id.
108    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):
1598class BoardCriterion(CtfyModel):
1599    """One key a board ranks by, in precedence order.
1600
1601    A board shows a rank and a number. When two rows carry the same
1602    number, what separated them was decided by the scoring rule's
1603    tie-break and is invisible to everyone reading the page — including
1604    the two teams it ranked. This says what the keys were.
1605
1606    ⚠️ **No value travels with it.** ``field`` names a
1607    :class:`ScoreboardEntry` attribute, and the client reads the number
1608    off the row it already holds — publishing the values again per row
1609    would be a second copy that can disagree with the figures printed
1610    beside them, which is the one failure a sort *explanation* must not
1611    have.
1612
1613    ⚠️ **Derived from the rule's own ``order``, never described beside
1614    it** — the criteria are what ``sort_key`` is built from, so this
1615    cannot drift from the sort it explains. See
1616    ``ctfy.server.scoring.base.SortCriterion``.
1617    """
1618
1619    #: A ``ScoreboardEntry`` field name. ``test_the_ordering_is_derived``
1620    #: checks every one against the model, because a name the model does
1621    #: not carry reaches the browser as ``undefined`` and renders as a
1622    #: blank cell beside a criterion claiming to have decided the rank.
1623    field: str = ""
1624    #: True when more is better. ⚠️ Not a "direction" string: the two
1625    #: values are a closed set of exactly two, and the one place a reader
1626    #: needs them is an arrow.
1627    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 BoardHarnessOption(ctfy.core.models.CtfyModel):
1320class BoardHarnessOption(CtfyModel):
1321    """A harness the ``harness_id`` filter can scope to, as this viewer sees it."""
1322
1323    #: The value to send as ``harness_id``; blank = the built-in harness.
1324    harness_id: str = ""
1325    #: Blank for the built-in harness: the label is the frontend's to localise.
1326    display_name: str = ""

A harness the harness_id filter can scope to, as this viewer sees it.

harness_id: str = ''
display_name: str = ''
class BoardRevision(ctfy.core.models.CtfyModel):
1303class BoardRevision(CtfyModel):
1304    """One corpus revision the scope's counted runs were graded on (#2733).
1305
1306    Enumerated by the server from the runs themselves, never scraped off one
1307    result page: a revision is an option because somebody's run recorded it.
1308    """
1309
1310    sha: str = ""
1311    runs: int = 0
1312    last_run_at: AwareDatetime | None = None
1313    #: The suite's own pin.
1314    pinned: bool = False
1315    #: The revision this deployment serves (``CTFY_CORPUS_SHA``): the only one
1316    #: whose manifest is known.
1317    current: bool = False

One corpus revision the scope's counted runs were graded on (#2733).

Enumerated by the server from the runs themselves, never scraped off one result page: a revision is an option because somebody's run recorded it.

sha: str = ''
runs: int = 0
last_run_at: pydantic.types.AwareDatetime | None = None
pinned: bool = False
current: bool = False
class BoardTotals(ctfy.core.models.CtfyModel):
1336class BoardTotals(CtfyModel):
1337    """How many rows matched the filters, before the page window (#2733)."""
1338
1339    pairs: int = 0
1340    models: int = 0
1341    unmeasured: int = 0

How many rows matched the filters, before the page window (#2733).

pairs: int = 0
models: int = 0
unmeasured: int = 0
class BoardVendorOption(ctfy.core.models.CtfyModel):
1329class BoardVendorOption(CtfyModel):
1330    """A vendor the ``vendor`` filter can name."""
1331
1332    vendor: str = ""
1333    display_name: str = ""

A vendor the vendor filter can name.

vendor: str = ''
display_name: str = ''
class BuildPushScopeRequest(ctfy.core.models.CtfyModel):
797class BuildPushScopeRequest(CtfyModel):
798    """What ``POST /admin/challenges/build-push`` freezes (Layer 2 P0).
799
800    An explicit scope is **required** — the plan freezes common families
801    first, manually, and defaulting to the whole ~2400-challenge corpus
802    would be an enormous unintended build campaign. Give a ``bucket``
803    (family directory such as ``XBOW``, matched case-insensitively) or an
804    explicit ``challenge_ids`` list, or both (their union). An empty
805    request is refused by the route.
806    """
807
808    #: Family / bucket directory to freeze — every challenge whose bucket
809    #: matches (e.g. ``XBOW``). ``""`` selects none by bucket.
810    bucket: str = ""
811    #: Explicit challenge ids to freeze, on top of any ``bucket`` match.
812    challenge_ids: list[str] = Field(default_factory=list)
813    #: Build host to run the freeze on. ``""`` picks the first online node
814    #: that runs each challenge; set it to freeze on one specific node
815    #: (e.g. the dedicated build host ``cb``).
816    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 BulkApproveRequest(ctfy.core.models.CtfyModel):
1100class BulkApproveRequest(CtfyModel):
1101    """``POST /admin/competitions/{id}/registration-review/bulk-approve``."""
1102
1103    teams: list[BulkApproveTeam] = Field(min_length=1, max_length=200)

POST /admin/competitions/{id}/registration-review/bulk-approve.

teams: list[BulkApproveTeam] = PydanticUndefined
class BulkApproveResponse(ctfy.core.models.CtfyModel):
1116class BulkApproveResponse(CtfyModel):
1117    """Per-team outcomes, in request order, plus the two totals the toast reads."""
1118
1119    approved: int = 0
1120    skipped: int = 0
1121    results: list[BulkApproveResult] = Field(default_factory=list)

Per-team outcomes, in request order, plus the two totals the toast reads.

approved: int = 0
skipped: int = 0
results: list[BulkApproveResult] = PydanticUndefined
class BulkApproveResult(ctfy.core.models.CtfyModel):
1106class BulkApproveResult(CtfyModel):
1107    """What happened to one team of a bulk approval."""
1108
1109    team_id: str
1110    team_name: str = ""
1111    approved: bool = False
1112    #: Every reason it was left for a person; empty exactly when ``approved``.
1113    skipped_for: list[BulkSkipReason] = Field(default_factory=list)

What happened to one team of a bulk approval.

team_id: str = PydanticUndefined
team_name: str = ''
approved: bool = False
skipped_for: list[typing.Literal['own_team', 'not_pending', 'resubmitted', 'new_member', 'edited_after_review', 'incomplete', 'eligibility_lapsed', 'conditions_unmet', 'exclusion_conflict', 'waitlisted', 'capacity_full', 'stale']] = PydanticUndefined
class BulkApproveTeam(ctfy.core.models.CtfyModel):
1089class BulkApproveTeam(CtfyModel):
1090    """One team in a bulk approval, with the token of the row the reviewer saw."""
1091
1092    team_id: str = Field(min_length=1, max_length=128)
1093    #: Required: a bulk approval is exactly the click that must not land on
1094    #: a team that changed since the page was drawn.
1095    review_token: str = Field(min_length=1, max_length=512)
1096    #: Any note the reviewer had typed on this team's card.
1097    note: str = Field(default="", max_length=2000)

One team in a bulk approval, with the token of the row the reviewer saw.

team_id: str = PydanticUndefined
review_token: str = PydanticUndefined
note: str = ''
class BulkCurationRequest(ctfy.core.models.CtfyModel):
1588class BulkCurationRequest(CtfyModel):
1589    """Body for ``POST /admin/competitions/{id}/authored-challenges/bulk-curation``
1590    (#2120): add the approved uploads of this competition to each target.
1591    ``challenge_ids`` narrows to those uploads (``None`` = every approved
1592    one); ``lock_versions`` turns on each target's version lock;
1593    ``confirm`` + ``reason`` are what a target locked in play asks (#2041)."""
1594
1595    target_competition_ids: list[str] = Field(min_length=1, max_length=BULK_CURATION_TARGET_LIMIT)
1596    challenge_ids: list[str] | None = Field(default=None, max_length=BULK_CURATION_UPLOAD_LIMIT)
1597    lock_versions: bool = True
1598    dry_run: bool = False
1599    confirm: bool = False
1600    reason: str = Field(default="", max_length=2000)

Body for POST /admin/competitions/{id}/authored-challenges/bulk-curation (#2120): add the approved uploads of this competition to each target. challenge_ids narrows to those uploads (None = every approved one); lock_versions turns on each target's version lock; confirm + reason are what a target locked in play asks (#2041).

target_competition_ids: list[str] = PydanticUndefined
challenge_ids: list[str] | None = None
lock_versions: bool = True
dry_run: bool = False
confirm: bool = False
reason: str = ''
class BulkCurationResponse(ctfy.core.models.CtfyModel):
1629class BulkCurationResponse(CtfyModel):
1630    source_competition_id: str
1631    dry_run: bool = False
1632    uploads: list[BulkCurationUpload] = Field(default_factory=list)
1633    targets: list[BulkCurationTarget] = Field(default_factory=list)
1634    results: list[BulkCurationResult] = Field(default_factory=list)
1635    #: Per outcome; only outcomes that occurred are present.
1636    counts: dict[str, int] = Field(default_factory=dict)

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.

source_competition_id: str = PydanticUndefined
dry_run: bool = False
uploads: list[BulkCurationUpload] = PydanticUndefined
targets: list[BulkCurationTarget] = PydanticUndefined
results: list[BulkCurationResult] = PydanticUndefined
counts: dict[str, int] = PydanticUndefined
class BulkCurationResult(ctfy.core.models.CtfyModel):
1621class BulkCurationResult(CtfyModel):
1622    challenge_id: str
1623    competition_id: str
1624    outcome: BulkCurationOutcome
1625    refusals: list[AuthoringRefusal] = Field(default_factory=list)
1626    warnings: list[AuthoringRefusal] = 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
competition_id: str = PydanticUndefined
outcome: Literal['added', 'already_curated', 'refused'] = PydanticUndefined
refusals: list[ctfy.core.authoring_refusals.AuthoringRefusal] = PydanticUndefined
warnings: list[ctfy.core.authoring_refusals.AuthoringRefusal] = PydanticUndefined
class BulkCurationTarget(ctfy.core.models.CtfyModel):
1612class BulkCurationTarget(CtfyModel):
1613    competition_id: str
1614    title: str = ""
1615    #: The target's version lock before this request.
1616    was_locked: bool = False
1617    #: Whether it is locked after it (on a preview: would be).
1618    locked: 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 = PydanticUndefined
title: str = ''
was_locked: bool = False
locked: bool = False
class BulkCurationUpload(ctfy.core.models.CtfyModel):
1603class BulkCurationUpload(CtfyModel):
1604    """One approved upload of the source, and the version that is live."""
1605
1606    id: str
1607    title: str = ""
1608    #: The live version's number (v1, v2, …); ``0`` when none is recorded.
1609    live_version: int = 0

One approved upload of the source, and the version that is live.

id: str = PydanticUndefined
title: str = ''
live_version: int = 0
class BulkInviteRequest(ctfy.core.models.CtfyModel):
3122class BulkInviteRequest(CtfyModel):
3123    """Body for ``POST /admin/competitions/{id}/invites/bulk``.
3124
3125    Organisers arrive with a qualifying list — a column pasted out of a
3126    spreadsheet, newline- or comma-separated, usually with stray spaces
3127    and the odd duplicate. Splitting it server-side keeps every client
3128    (web, CLI, SDK) from having to agree on the same rules.
3129    """
3130
3131    # One blob rather than a parsed list: a textarea is what the organiser
3132    # actually has, and asking the client to split it invites three
3133    # slightly different splitters.
3134    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):
3150class BulkInviteResponse(CtfyModel):
3151    """Per-address outcomes plus the tallies the toast reports.
3152
3153    Every address is echoed back rather than only the failures: an
3154    organiser pasting 300 lines needs to see that 300 were understood,
3155    and a silent drop is indistinguishable from success.
3156    """
3157
3158    results: list[BulkInviteResultRow] = Field(default_factory=list)
3159    invited: int = 0
3160    pending: int = 0
3161    already: int = 0
3162    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):
3137class BulkInviteResultRow(CtfyModel):
3138    """What happened to one address. ``status`` is one of:
3139
3140    ``invited`` — a new allowlist row (the user existed);
3141    ``pending`` — recorded against the address, waiting for a sign-up;
3142    ``already`` — invited before, so nothing changed;
3143    ``invalid`` — not an email address, skipped.
3144    """
3145
3146    email: str
3147    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):
682class CalendarBucket(CtfyModel):
683    """One UTC-day cell on the contribution calendar.
684
685    ``count`` aggregates correct submissions + first-time solves on that
686    day; effectively "did anything productive happen". The series is
687    dense — every day in the requested window is present, zero-filled."""
688
689    date: str = ""  # YYYY-MM-DD, UTC
690    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 CapacityWatchResult(ctfy.core.models.CtfyModel):
1000class CapacityWatchResult(CtfyModel):
1001    """The caller's "tell me when a place frees" after the write."""
1002
1003    competition_id: str
1004    watching: bool = False

The caller's "tell me when a place frees" after the write.

competition_id: str = PydanticUndefined
watching: bool = False
class CaseCloseRequest(ctfy.core.models.CtfyModel):
34class CaseCloseRequest(CtfyModel):
35    """Body for dismissing a case. A note is optional: dismissing is the
36    organisers saying *nothing to see here*, and they may not want to say why."""
37
38    note: str = Field(default="", max_length=2000)

Body for dismissing a case. A note is optional: dismissing is the organisers saying nothing to see here, and they may not want to say why.

note: str = ''
class ChallengeBlueprint(ctfy.core.models.CtfyModel):
111class ChallengeBlueprint(CtfyModel):
112    """A range's topology and kill chain, for the people running it.
113
114    ⚠️ **Derived, never carried.** Every field is read off the
115    challenge's own ``metadata.yaml`` and ``docker-compose.yml``, which
116    the deployment already has for every benchmark in the corpus. A
117    generator emitting a sidecar plan file would have covered only
118    generated ranges — leaving the hand-built ones, which are the reason
119    the format exists, with no view at all — and would have been a
120    second copy of facts the compose already carries, free to disagree
121    with the estate that actually boots.
122
123    ⚠️ **No groundtruth here.** This model says *what a milestone is and
124    where it lives*, never what it is worth. The value of a minted
125    answer is a per-instance fact and lives on the instance route, where
126    reading it is an audited act.
127    """
128
129    challenge_id: str
130    segments: list[str] = Field(default_factory=list)
131    hosts: list[BlueprintHostInfo] = Field(default_factory=list)
132    milestones: list[BlueprintMilestoneInfo] = Field(default_factory=list)
133    #: Milestones no service carries. ⚠️ Projected rather than dropped:
134    #: a page claiming an estate is fully mapped when a sixth of it is
135    #: not is worse than no page.
136    unplaced: list[str] = Field(default_factory=list)
137    #: Nothing is published — no ``ports:`` anywhere and no entry the
138    #: platform injects in their place — so ``behind_pivot`` has no
139    #: meaning and is False throughout. An engagement range whose entry
140    #: the platform injects is *not* this, whatever its compose says.
141    tunnelled: bool = False
142    #: Defects in the range's own metadata that no other surface reports
143    #: (a ``requires`` cycle, a ``requires`` naming nothing).
144    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):
819class ChallengeBuildKickoffNodeResult(CtfyModel):
820    """One node's response to a build kickoff (single or all).
821
822    ``queued`` is populated only on the ``build-all`` fan-out; for the
823    single-challenge variant the platform inspects ``status`` to learn
824    what the node accepted. ``""`` is *the node did not say* — the
825    bulk fan-out, or a node that could not be reached — which is a
826    different fact from any build status and so is its own member.
827    """
828
829    node_id: str
830    ok: bool
831    status: ChallengeBuildStatus | Literal[""] = ""
832    queued: list[str] = Field(default_factory=list)
833    skipped_built: list[str] = Field(default_factory=list)
834    skipped_in_progress: list[str] = Field(default_factory=list)
835    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):
838class ChallengeBuildKickoffResponse(CtfyModel):
839    """Admin ``POST /admin/challenges/{id}/build`` and ``…/build-all`` response."""
840
841    challenge_id: str = ""  # empty for build-all
842    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):
755class ChallengeBuildNodeState(CtfyModel):
756    """One worker node's view of a single challenge's build state.
757
758    ``node_error`` is non-empty only when the platform could not reach
759    the node at all (heartbeat stale / 5xx) — in that case ``status`` is
760    forced to ``unbuilt`` for the aggregate.
761    """
762
763    node_id: str
764    status: ChallengeBuildStatus = ChallengeBuildStatus.UNBUILT
765    built_at: float = 0.0
766    error: str = ""
767    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):
791class ChallengeBuildStateResponse(CtfyModel):
792    """Admin ``GET /admin/challenges/build-state`` response."""
793
794    rows: list[ChallengeBuildStateRow] = Field(default_factory=list)

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

rows: list[ChallengeBuildStateRow] = PydanticUndefined
class ChallengeBuildStateRow(ctfy.core.models.CtfyModel):
770class ChallengeBuildStateRow(CtfyModel):
771    """Per-challenge build state aggregated across every online node.
772
773    ``aggregated`` rolls up ``nodes`` using a worst-case rule so the
774    table's single status column behaves intuitively:
775
776    * any ``building`` → ``building`` (yellow)
777    * any ``failed``  → ``failed`` (red)
778    * any ``unbuilt`` → ``unbuilt`` (grey)
779    * else            → ``built`` (green)
780
781    A node that couldn't be reached at all contributes ``unbuilt`` so
782    the aggregate stays conservative — the admin still sees a
783    "missing" pip and can drill into the modal to find out why.
784    """
785
786    challenge_id: str
787    aggregated: ChallengeBuildStatus = ChallengeBuildStatus.UNBUILT
788    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 ChallengeDifficultyReading(ctfy.core.models.CtfyModel):
75class ChallengeDifficultyReading(CtfyModel):
76    """The label against what players did."""
77
78    #: The author's own label; ``""`` when the challenge states none.
79    stated: str
80    #: The label whose event-wide mean solve rate is nearest this one's.
81    actual: str
82    solve_rate: float | None = None
83    #: Mean solve rate of the challenges sharing the stated label.
84    stated_mean_rate: float | None = None

The label against what players did.

stated: str = PydanticUndefined
actual: str = PydanticUndefined
solve_rate: float | None = None
stated_mean_rate: float | None = None
class ChallengeEnvironment(ctfy.core.models.CtfyModel):
87class ChallengeEnvironment(CtfyModel):
88    """What running the box cost, from the instance archive."""
89
90    starts: int
91    failed_starts: int
92    start_success_rate: float | None = None
93    mean_start_s: float | None = None
94    slowest_start_s: float | None = None
95    occupied_s: float
96    resets: int
97    #: Chips that mean the challenge is broken (``buggy``, ``infra_broken``).
98    problem_reports: int

What running the box cost, from the instance archive.

starts: int = PydanticUndefined
failed_starts: int = PydanticUndefined
start_success_rate: float | None = None
mean_start_s: float | None = None
slowest_start_s: float | None = None
occupied_s: float = PydanticUndefined
resets: int = PydanticUndefined
problem_reports: int = PydanticUndefined
class ChallengeFacetCount(ctfy.core.models.CtfyModel):
643class ChallengeFacetCount(CtfyModel):
644    """One bucket on the challenge catalog facet summary.
645
646    ``value`` is the difficulty / tag string; ``total`` is how many
647    challenges in scope carry it. ``solved`` is the *calling* user's
648    solved count in that bucket (across every team they have been on);
649    it is 0 for anonymous callers and for tag buckets (the UI only
650    renders a solved/total ratio for difficulty).
651    """
652
653    value: str
654    total: int = 0
655    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):
658class ChallengeFacets(CtfyModel):
659    """Catalog aggregates for the Challenges page side panels + filter
660    pill counts — served by ``GET /challenges/facets`` so the page no
661    longer derives them from a bulk fetch of the whole catalog.
662
663    Scoped by ``competition_id`` (when given) exactly like the list
664    endpoint, so the pills/charts reflect the same subset the paged
665    list pages through.
666    """
667
668    total: int = 0
669    difficulty: list[ChallengeFacetCount] = Field(default_factory=list)
670    # Per-category counts (only categories with ≥1 challenge in scope
671    # show up). Ordered by the canonical category sequence so the
672    # filter pills always render web → pwn → reverse → crypto → misc.
673    category: list[ChallengeFacetCount] = Field(default_factory=list)
674    tags: list[ChallengeFacetCount] = Field(default_factory=list)
675    # Per-status counts for the calling caller (``solved`` / ``unsolved``
676    # / ``running``). Drives the count chip on each status filter pill
677    # so the page doesn't N+1 a status-filtered list per pill. Buckets
678    # are always present (in canonical order) for layout stability; on
679    # anonymous callers every count is 0 — never leaking another
680    # team's state.
681    status: list[ChallengeFacetCount] = Field(default_factory=list)
682    # Per-source-bucket counts — the prefix of a challenge id
683    # (``CVE-2021-1234`` → ``CVE``), i.e. the benchmark suite an item
684    # was ingested from. Ordered by count descending then id, so a
685    # caller rendering a top-N breakdown gets a stable, meaningful
686    # slice order without re-sorting. ``solved`` is always 0 here:
687    # this facet answers "what is the catalog made of", not "how am I
688    # doing", and the landing page that consumes it is anonymous.
689    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 ChallengeFunnel(ctfy.core.models.CtfyModel):
44class ChallengeFunnel(CtfyModel):
45    """Entrants at each step: people who opened the challenge, then teams that
46    started a box, answered and solved it."""
47
48    #: People who opened the challenge's detail (#2285), de-duplicated per
49    #: person. ``None`` = views were **not recorded** for this event (it ran
50    #: before they were), which is not ``0``: a zero would claim nobody looked.
51    viewed: int | None = None
52    started: int
53    submitted: int
54    solved: int
55    #: Teams that started a box and never answered.
56    started_never_submitted: int

Entrants at each step: people who opened the challenge, then teams that started a box, answered and solved it.

viewed: int | None = None
started: int = PydanticUndefined
submitted: int = PydanticUndefined
solved: int = PydanticUndefined
started_never_submitted: int = PydanticUndefined
class ChallengeInfo(ctfy.core.models.CtfyModel):
421class ChallengeInfo(CtfyModel):
422    id: str
423    name: str
424    category: ChallengeCategory = ChallengeCategory.WEB
425    difficulty: str = ""
426    description: str = ""
427    # Full-challenge solves: teams that captured every declared
428    # question (across all modes — dynamic + static + select).
429    solves_count: int = 0
430    # Times this challenge has been instantiated, ever — one per
431    # archived InstanceRecord (every terminal path writes one). A
432    # "how much has this been run" signal for the challenge card;
433    # platform-wide, not scoped to the viewing team or competition.
434    launch_count: int = 0
435    tags: list[str] = Field(default_factory=list)
436    # Every question declared on this challenge, in author-declared
437    # order. Single-question challenges have a single entry with
438    # id ``"flag"``. ``QuestionPublicInfo`` exposes prompt/mode/choices
439    # but never the answer. Empty for a player reading a silent or
440    # practice exercise: the row count is the objective total (#2435).
441    questions: list[QuestionPublicInfo] = Field(default_factory=list)
442    # Per-question solve counts (how many teams captured each
443    # question). Keyed by question id. Useful for the challenge
444    # detail page's progress breakdown. Emptied (not zeroed) for a
445    # player reading an exercise, because its keys are that same
446    # objective list. Counts span every mode;
447    # the multi_select grader records one solve per fully-correct
448    # submission, not per chosen choice.
449    flag_solves: dict[str, int] = Field(default_factory=dict)
450    # Files shipped under the challenge's ``attachments/`` directory,
451    # downloadable via ``GET /challenges/{id}/attachments/{name}``.
452    # Empty for pure-network challenges that ship nothing.
453    attachments: list[AttachmentInfo] = Field(default_factory=list)
454    # True for pure question-answer challenges (``category: misc``,
455    # no ``docker-compose.yml`` on disk, every declared question
456    # carries a static ``answer:``). The frontend keys off this flag
457    # to switch the competition surface to a quiz UI and skip the
458    # launch-confirm flow. Source of truth lives in
459    # :attr:`ctfy.core.challenge.ChallengeSpec.is_qa_only`, ultimately
460    # derived from :attr:`BenchmarkContext.is_qa_only`.
461    is_qa_only: bool = False
462    # Set for AWD+ challenges; ``None`` for every other kind. Same role
463    # as ``is_qa_only`` one field up — the flag a client keys off to
464    # switch surface — but an object rather than a bool because a
465    # defence surface needs to know *what* it may send, not only that it
466    # may send something.
467    defence: DefenceInfo | None = None
468    #: Whether an instance of this challenge, started in this listing's
469    #: competition, can reach the public internet (#1969). ``deny`` means
470    #: the box cannot download tools, call public APIs or resolve public
471    #: names — bring what you need — while the challenge's own services
472    #: and the platform's (the LLM gateway) stay reachable. Decided by the
473    #: competition's policy and its organiser, never by the author alone;
474    #: the global catalog (no competition) is ``allow``. The started
475    #: instance carries the verdict it actually got
476    #: (``InstanceInfo.internet``).
477    internet: InternetAccess = InternetAccess.ALLOW
478    #: The author's own declaration: this challenge needs the internet at
479    #: runtime, and why (``internet_reason``). ``internet_required`` with
480    #: ``internet: deny`` is a conflict the organiser was shown — the
481    #: challenge may not be solvable as its author intended. The reason is
482    #: withheld wherever ``tags`` are (a silent exercise's estate profile).
483    internet_required: bool = False
484    internet_reason: str = ""
485    #: Seconds a **cold** start of this challenge usually takes (p50), set
486    #: only when some node it could land on does not hold its images yet and
487    #: its recent cold starts are notably slow (#2129,
488    #: ``ctfy.server.start_wait``). ``None`` = say nothing: no node is cold,
489    #: the start is quick, or fewer than five cold starts stand behind a
490    #: figure. A client reads ``None`` as *no caption*, never as *instant*.
491    expected_start_s: int | None = None
492    #: Several teams stand on one environment of this challenge in the
493    #: competition (#2129 B) -- the "shared" chip on the card. ⚠️ A flag and
494    #: nothing more: whether that is the whole field or a group, and how big,
495    #: is the secret the feature keeps. ``False`` unscoped.
496    shared: bool = False
497    #: Seconds a start of this challenge in the listing's competition runs
498    #: for (#2722): the shared environment's lifetime when ``shared``, else
499    #: the platform default -- ``instance_start.launch_ttl``, the rule the
500    #: start itself applies. The launch dialog states it. ``0`` = say
501    #: nothing (an older server), never "no lifetime".
502    launch_ttl_s: int = 0
503    #: When this challenge closes inside the listing's competition (#2093),
504    #: ``None`` when it has no close time (the old behaviour, and every
505    #: unscoped listing). Read from the clock like the schedule itself.
506    close_at: datetime | None = None
507    #: Whether it has closed **for this viewer** -- submissions, new instances
508    #: and resets are refused with ``challenge_closed``. ⚠️ Defaults False,
509    #: the quiet direction (an older server never paints a closed card), and
510    #: False for an organiser, who is never refused: ``close_at`` still says
511    #: when the clock closes it for everyone else.
512    closed: bool = False
513    #: Whether playing it now scores (#2183): ``scored``; ``closed`` (its
514    #: close time or the competition's end has passed and the competition
515    #: stops play -- ``closed`` above is the per-viewer refusal); or
516    #: ``practice`` (closed, but the competition lets teams practise: it can
517    #: be started and answered, and nothing it records scores). An agent
518    #: should skip ``practice`` challenges when it plays to score. ``scored``
519    #: from an older server and for every unscoped listing.
520    scoring: ChallengeScoring = "scored"
521    #: The questions the caller's team has answered right **in practice**
522    #: (#2183) -- never a solve, never on a board. ``[]`` outside practice.
523    practice_question_ids: list[str] = Field(default_factory=list)
524    #: How many hint tiers of this challenge are released in the listing's
525    #: competition (#2101), and when the newest was — the card's "新提示"
526    #: marker. A count of *released* tiers only: a locked tier says nothing
527    #: until it opens. ``0`` / ``None`` unscoped.
528    hints_released: int = 0
529    hint_released_at: datetime | None = None
530    #: Pinned to the head of the listing's competition by its organisers
531    #: (#2070) — everybody sees the marker. ``False`` unscoped: a pin is a
532    #: fact about one event's list, never about the challenge.
533    pinned: bool = False
534    #: The viewer's own star (#2070): private, stamped per request outside the
535    #: shared projection, ``False`` for an anonymous reader.
536    favourited: bool = False
537    # ---- viewer-scoped: how far in is the caller -----------------------
538    #
539    # Populated only for an authenticated request; a guest gets the
540    # empty/False sentinels, which is what the server can honestly say
541    # about somebody it never looked up. Scoped by ``competition_id``
542    # exactly like the tallies above — unscoped they span every team the
543    # caller has been on, which is the standalone catalog's own
544    # platform-wide question and the wrong answer to "have I solved it
545    # *here*".
546    #
547    # ⚠️ **Two subjects, because the platform has two and they disagree
548    # on any roster with more than one human.** Every scoring surface
549    # here means the *team*: ``solves_count`` groups by ``team_id``,
550    # ``InstanceQuestionInfo.answered_correctly`` reads
551    # ``solved_flag_ids(team_id, …)``, ``QaChallengeView`` reads
552    # ``list_solves(team_id=…)``. ``/me/solves`` means the *person*
553    # (``Solve.user_id``, stamped at submit time), which is right for a
554    # profile page and was what the challenge card had been reading — so
555    # a team-mate who had not personally submitted saw 未解出 on a
556    # challenge their team already owned and could not re-earn.
557    #
558    # ⚠️ **A badge should render ``my_team_*``.** That is the subject the
559    # board, the attempt cap and first blood all use; ``my_*`` is the
560    # personal contribution split, and reading it as "solved" is the bug
561    # these four fields were added to end.
562    #
563    # ⚠️ Empty under a silent exercise, never a partial truth: §4b's
564    # channel 2 is the player's own verdict, and ``own_progress_hidden``
565    # is the gate. The id lists are subsets of what the caller's team
566    # captured, so they can only name questions that team has already
567    # unlocked — see ``projections.viewer_progress``.
568    #: Question ids **this person** captured. ⊆ ``my_team_solved_question_ids``.
569    my_solved_question_ids: list[str] = Field(default_factory=list)
570    #: Whether this person alone captured every declared question.
571    my_fully_solved: bool = False
572    #: Question ids **the caller's team** captured — the scoring subject.
573    my_team_solved_question_ids: list[str] = Field(default_factory=list)
574    #: Whether the caller's team captured every declared question. This
575    #: is the one a "solved" badge means.
576    my_team_fully_solved: bool = False
577    #: Whether the caller's team wrote this challenge — in a
578    #: peer-authoring event, whether solving it can pay them anything.
579    #:
580    #: ⚠️ **The fact existed and reached the player only after the
581    #: work.** `record_correct_answer` writes no solve row for an
582    #: author, and `SubmissionResponse.unranked_reason` says why — on
583    #: the *submission*, which is to say after they opened an instance,
584    #: solved the challenge and pressed submit. Measured on the real
585    #: route: an author's own upload and a stranger's came back from
586    #: `GET /competitions/{id}/challenges` with identical fields and
587    #: nothing about authorship anywhere. `unranked_reason`'s own
588    #: comment already called that moment a dead end — the copy under
589    #: ``own_authored`` is "advice that can never pay off".
590    #:
591    #: ⚠️ **``my_team_*``, deliberately, for its neighbours' reason.**
592    #: The board's subject is the team, so the question this answers is
593    #: *can my team score on this* — not *did I personally upload it*,
594    #: which would leave a captain's team-mates unwarned about a
595    #: challenge their own squad wrote.
596    #:
597    #: ⚠️ **Marks, never hides.** An author must be able to launch and
598    #: submit — it is the only way to check from outside that the
599    #: challenge they uploaded is answerable at all, which is
600    #: `authored_scoring`'s stated requirement rather than a
601    #: concession. Filtering these out of the listing would make the
602    #: one entrant who can fix a broken challenge the one who cannot
603    #: reach it.
604    #:
605    #: ⚠️ Defaults **False**, the quiet direction: a producer that did
606    #: not ask, or an older server, must not tell a team a challenge is
607    #: theirs and cost them one they could have scored on. The other
608    #: default's failure is silent and expensive; this one's is the
609    #: state before this field existed.
610    my_team_authored: bool = False
611    #: Who wrote this challenge, for a player-uploaded one whose author
612    #: asked to be named (plan P2.5) — their display name, never an
613    #: address — and ``""`` for everything else: the corpus, an upload
614    #: credited anonymously, an author with no display name, one whose
615    #: entry in the event they uploaded to is withheld from this viewer,
616    #: and one a competition this viewer can see is showing while it is
617    #: still live (#1927 — the name arrives when the last one ends).
618    #:
619    #: ⚠️ **Written by one function and nowhere else**
620    #: (`ctfy.server.anonymity.anonymise_author_credits`), and the
621    #: projection leaves it blank — so a route that forgets to ask names
622    #: nobody, the direction that fails closed. Every upload comes from an
623    #: entrant, so a name here also says *they entered that event*, and
624    #: that event's anonymity policy decides it before the grant does.
625    author_display_name: str = ""
626    #: The same author's account id, set exactly where the name is and by
627    #: the same function — what the detail page links to their profile's
628    #: list of uploads with. ``""`` wherever the name is.
629    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
internet: ctfy.core.enums.InternetAccess = <InternetAccess.ALLOW: 'allow'>
internet_required: bool = False
internet_reason: str = ''
expected_start_s: int | None = None
shared: bool = False
launch_ttl_s: int = 0
close_at: datetime.datetime | None = None
closed: bool = False
scoring: Literal['scored', 'closed', 'practice'] = 'scored'
practice_question_ids: list[str] = PydanticUndefined
hints_released: int = 0
hint_released_at: datetime.datetime | None = None
pinned: bool = False
favourited: bool = False
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 ChallengeInternetRow(ctfy.core.models.CtfyModel):
479class ChallengeInternetRow(CtfyModel):
480    """One challenge of a competition: what its author asked, what it gets (#1969)."""
481
482    challenge_id: str
483    name: str = ""
484    #: The author's declaration (``x-ctfy-internet`` in the challenge's
485    #: compose file): it needs the internet at runtime, and why.
486    internet_required: bool = False
487    internet_reason: str = ""
488    #: The organiser's per-challenge override, ``None`` when the challenge
489    #: follows the competition's ``internet_policy``.
490    override: InternetAccess | None = None
491    #: What an instance started here now gets.
492    internet: InternetAccess = InternetAccess.ALLOW
493    #: The author says it needs the internet and it does not get it. The
494    #: organiser's rule stands — this is reported, never silently undone.
495    conflict: bool = False

One challenge of a competition: what its author asked, what it gets (#1969).

challenge_id: str = PydanticUndefined
name: str = ''
internet_required: bool = False
internet_reason: str = ''
override: ctfy.core.enums.InternetAccess | None = None
internet: ctfy.core.enums.InternetAccess = <InternetAccess.ALLOW: 'allow'>
conflict: bool = False
class ChallengePerformance(ctfy.core.models.CtfyModel):
207class ChallengePerformance(CtfyModel):
208    """The organiser's table over every challenge of one competition."""
209
210    competition_id: str
211    audience: Audience
212    withheld: bool = False
213    rows: list[ChallengePerformanceRow] = Field(default_factory=list)
214    #: ``{label: mean solve rate}`` the actual-difficulty reading is made against.
215    label_mean_rates: dict[str, float] = Field(default_factory=dict)

The organiser's table over every challenge of one competition.

competition_id: str = PydanticUndefined
audience: Literal['all', 'human', 'agent'] = PydanticUndefined
withheld: bool = False
rows: list[ChallengePerformanceRow] = PydanticUndefined
label_mean_rates: dict[str, float] = PydanticUndefined
class ChallengePerformanceRow(ctfy.core.models.CtfyModel):
184class ChallengePerformanceRow(CtfyModel):
185    """One challenge in the organiser's overview."""
186
187    challenge_id: str
188    name: str
189    category: ChallengeCategory | None = None
190    stated_difficulty: str
191    actual_difficulty: str
192    started: int
193    submitted: int
194    solved: int
195    solve_rate: float | None = None
196    first_solved_at: AwareDatetime | None = None
197    first_solve_after_start_s: float | None = None
198    median_solve_s: float | None = None
199    wrong_submissions: int
200    start_failure_rate: float | None = None
201    problem_reports: int
202    #: Finishes per slice of the event so far, oldest first -- the sparkline.
203    solve_trend: list[int]
204    anomalies: list[Anomaly]

One challenge in the organiser's overview.

challenge_id: str = PydanticUndefined
name: str = PydanticUndefined
category: ctfy.challenge.benchmark.ChallengeCategory | None = None
stated_difficulty: str = PydanticUndefined
actual_difficulty: str = PydanticUndefined
started: int = PydanticUndefined
submitted: int = PydanticUndefined
solved: int = PydanticUndefined
solve_rate: float | None = None
first_solved_at: pydantic.types.AwareDatetime | None = None
first_solve_after_start_s: float | None = None
median_solve_s: float | None = None
wrong_submissions: int = PydanticUndefined
start_failure_rate: float | None = None
problem_reports: int = PydanticUndefined
solve_trend: list[int] = PydanticUndefined
anomalies: list[typing.Literal['no_first_solve', 'buggy_reports', 'start_failures', 'difficulty_mismatch']] = PydanticUndefined
class ChallengePullKickoffNodeResult(ctfy.core.models.CtfyModel):
890class ChallengePullKickoffNodeResult(CtfyModel):
891    """One node's response to a pull kickoff (single or all).
892
893    ``status`` reads the same way as the build-side twin: ``""`` is *the
894    node did not say*, not a pull status.
895    """
896
897    node_id: str
898    ok: bool
899    status: ChallengePullStatus | Literal[""] = ""
900    queued: list[str] = Field(default_factory=list)
901    skipped_pulled: list[str] = Field(default_factory=list)
902    skipped_in_progress: list[str] = Field(default_factory=list)
903    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):
906class ChallengePullKickoffResponse(CtfyModel):
907    """Admin ``POST /admin/challenges/{id}/pull`` and ``…/pull-all`` response."""
908
909    challenge_id: str = ""  # empty for pull-all
910    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):
852class ChallengePullNodeState(CtfyModel):
853    """One worker node's view of a single challenge's pull state.
854
855    ``node_error`` is non-empty only when the platform could not reach
856    the node at all — ``status`` is then forced to ``unpulled`` for the
857    aggregate.
858    """
859
860    node_id: str
861    status: ChallengePullStatus = ChallengePullStatus.UNPULLED
862    pulled_at: float = 0.0
863    error: str = ""
864    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):
884class ChallengePullStateResponse(CtfyModel):
885    """Admin ``GET /admin/challenges/pull-state`` response."""
886
887    rows: list[ChallengePullStateRow] = Field(default_factory=list)

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

rows: list[ChallengePullStateRow] = PydanticUndefined
class ChallengePullStateRow(ctfy.core.models.CtfyModel):
867class ChallengePullStateRow(CtfyModel):
868    """Per-challenge pull state aggregated across every online node.
869
870    ``aggregated`` rolls up ``nodes`` worst-case, mirroring the build
871    row:
872
873    * any ``pulling`` → ``pulling`` (yellow)
874    * any ``failed``  → ``failed`` (red)
875    * any ``unpulled`` → ``unpulled`` (grey)
876    * else            → ``pulled`` (green)
877    """
878
879    challenge_id: str
880    aggregated: ChallengePullStatus = ChallengePullStatus.UNPULLED
881    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 ChallengeReceptionStats(ctfy.core.models.CtfyModel):
150class ChallengeReceptionStats(CtfyModel):
151    """One challenge's reading for one audience."""
152
153    challenge_id: str
154    competition_id: str
155    name: str = ""
156    category: ChallengeCategory | None = None
157    audience: Audience
158    withheld: bool = False
159    funnel: ChallengeFunnel | None = None
160    solve_curve: list[SolvePoint] | None = None
161    first_solved_at: AwareDatetime | None = None
162    #: First finish after the event opened, seconds.
163    first_solve_after_start_s: float | None = None
164    median_solve_s: float | None = None
165    #: Counts per bucket of :data:`ctfy.core.challenge_stats.BUCKET_EDGES_S`
166    #: plus one open bucket; ``bucket_edges_s`` travels with it.
167    solve_time_buckets: list[int] | None = None
168    bucket_edges_s: list[int] | None = None
169    difficulty: ChallengeDifficultyReading | None = None
170    wrong_submissions: int | None = None
171    wrong_per_solver: float | None = None
172    #: ``None`` when this reader may not see them (``wrong_answers_visible``).
173    wrong_answers: list[WrongAnswer] | None = None
174    wrong_answers_visible: bool = False
175    #: When an author's view opens; ``None`` for an organiser (always open)
176    #: and for an event with no end.
177    wrong_answers_after: AwareDatetime | None = None
178    environment: ChallengeEnvironment | None = None
179    reactions: list[ReactionCount] | None = None
180    #: Which teams played it (#2285); ``None`` while ``withheld``.
181    team_detail: ChallengeTeamDetail | None = None

One challenge's reading for one audience.

challenge_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
name: str = ''
category: ctfy.challenge.benchmark.ChallengeCategory | None = None
audience: Literal['all', 'human', 'agent'] = PydanticUndefined
withheld: bool = False
funnel: ChallengeFunnel | None = None
solve_curve: list[SolvePoint] | None = None
first_solved_at: pydantic.types.AwareDatetime | None = None
first_solve_after_start_s: float | None = None
median_solve_s: float | None = None
solve_time_buckets: list[int] | None = None
bucket_edges_s: list[int] | None = None
difficulty: ChallengeDifficultyReading | None = None
wrong_submissions: int | None = None
wrong_per_solver: float | None = None
wrong_answers: list[WrongAnswer] | None = None
wrong_answers_visible: bool = False
wrong_answers_after: pydantic.types.AwareDatetime | None = None
environment: ChallengeEnvironment | None = None
reactions: list[ReactionCount] | None = None
team_detail: ChallengeTeamDetail | None = None
class ChallengeRescanNodeResult(ctfy.core.models.CtfyModel):
692class ChallengeRescanNodeResult(CtfyModel):
693    """Outcome of fanning the rescan out to one worker node.
694
695    ``ok`` is False for an offline node (skipped, never contacted) or a
696    node that errored; ``total`` is the node's post-rescan challenge
697    count when reached, else ``None``. One unreachable node never fails
698    the whole operation — the platform rescan still stands.
699    """
700
701    node_id: str
702    ok: bool
703    total: int | None = None
704    # ⚠️ ``""``, not ``None``. This is the producer of the ``null`` that
705    # ten durable task rows carry and that ``TaskNodeLine`` then refused
706    # — one 500 for the whole admin task list. Absent and empty assert
707    # the same thing for an error string, unlike ``total`` above where
708    # ``None`` means "this producer has no such concept".
709    error: str = ""
710    #: #2240: the corpus commit this node last reported, and whether it is
711    #: the platform's. ``None`` = one side is unreadable, not "in step".
712    corpus_commit: str = ""
713    corpus_in_step: bool | None = None
714    #: Corpus challenges the platform lists and this node's rescan does not
715    #: (the first ``RESCAN_MISSING_SHOWN``). ``missing_count`` is ``None``
716    #: when the node did not return its catalog (an older build) — *not
717    #: compared*, never "nothing missing".
718    missing: list[str] = Field(default_factory=list)
719    missing_count: int | None = None
720    #: #3385: buckets this node's rescan linked into / unlinked from its own
721    #: catalog root. Empty from a node that predates relaying the farm.
722    linked: list[str] = Field(default_factory=list)
723    unlinked: list[str] = Field(default_factory=list)

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 = ''
corpus_commit: str = ''
corpus_in_step: bool | None = None
missing: list[str] = PydanticUndefined
missing_count: int | None = None
linked: list[str] = PydanticUndefined
unlinked: list[str] = PydanticUndefined
class ChallengeRescanResult(ctfy.core.models.CtfyModel):
726class ChallengeRescanResult(CtfyModel):
727    """Admin ``POST /admin/challenges/rescan`` response.
728
729    ``total`` / ``added`` / ``removed`` describe the platform's own
730    catalog after re-scanning ``challenges_dir``; ``nodes`` carries the
731    per-worker fan-out outcome so a newly added challenge is confirmed
732    launchable, not just listable.
733    """
734
735    total: int
736    added: list[str] = Field(default_factory=list)
737    removed: list[str] = Field(default_factory=list)
738    nodes: list[ChallengeRescanNodeResult] = Field(default_factory=list)
739    #: #2240: the platform's own corpus commit, and how many reached nodes
740    #: diverge from it — at another commit, or missing a corpus challenge.
741    platform_corpus_commit: str = ""
742    diverged: int = 0
743    #: #3385: corpus buckets the platform's rescan linked into its catalog
744    #: root, and links it removed for buckets the corpus dropped — so a new
745    #: bucket never reads as ``+0/-0``.
746    linked: list[str] = Field(default_factory=list)
747    unlinked: list[str] = 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
platform_corpus_commit: str = ''
diverged: int = 0
linked: list[str] = PydanticUndefined
unlinked: list[str] = PydanticUndefined
class ChallengeScheduleRow(ctfy.core.models.CtfyModel):
2659class ChallengeScheduleRow(CtfyModel):
2660    """One challenge of a competition and when it appears and shuts (#2093).
2661
2662    ``released`` and ``closed`` are read from the clock when the answer is
2663    composed — nothing stores them. A challenge with no times is released
2664    from the competition's start and never closes: ``reveal_at`` and
2665    ``close_at`` are ``null``.
2666    """
2667
2668    challenge_id: str
2669    #: The wave this challenge belongs to; ``""`` is none.
2670    batch: str = ""
2671    reveal_at: datetime | None = None
2672    close_at: datetime | None = None
2673    released: bool = True
2674    closed: bool = False

One challenge of a competition and when it appears and shuts (#2093).

released and closed are read from the clock when the answer is composed — nothing stores them. A challenge with no times is released from the competition's start and never closes: reveal_at and close_at are null.

challenge_id: str = PydanticUndefined
batch: str = ''
reveal_at: datetime.datetime | None = None
close_at: datetime.datetime | None = None
released: bool = True
closed: bool = False
class ChallengeScheduleUpdate(ctfy.core.models.CtfyModel):
2786class ChallengeScheduleUpdate(CtfyModel):
2787    """An organiser's edit to the schedule of several challenges at once.
2788
2789    **Which challenges**: the ``challenge_ids`` named, plus every member of
2790    the batch ``in_batch`` names (at least one of the two). **What to set**
2791    is partial (``model_fields_set``): a field the request omits is left
2792    alone, and ``null`` (or ``""`` for ``batch``) clears it.
2793
2794    ``release_now`` sets ``reveal_at`` to the instant the request is served
2795    — the manual one-click release. It cannot be combined with ``reveal_at``.
2796    """
2797
2798    challenge_ids: list[str] = Field(default_factory=list, max_length=2000)
2799    in_batch: str = Field(default="", max_length=64)
2800    batch: str | None = Field(default=None, max_length=64)
2801    reveal_at: datetime | None = None
2802    close_at: datetime | None = None
2803    release_now: bool = False
2804
2805    _norm_times = field_validator("reveal_at", "close_at", mode="before")(_blank_to_none)

An organiser's edit to the schedule of several challenges at once.

Which challenges: the challenge_ids named, plus every member of the batch in_batch names (at least one of the two). What to set is partial (model_fields_set): a field the request omits is left alone, and null (or "" for batch) clears it.

release_now sets reveal_at to the instant the request is served — the manual one-click release. It cannot be combined with reveal_at.

challenge_ids: list[str] = PydanticUndefined
in_batch: str = ''
batch: str | None = None
reveal_at: datetime.datetime | None = None
close_at: datetime.datetime | None = None
release_now: bool = False
class ChallengeSolveAttempt(ctfy.core.models.CtfyModel):
913class ChallengeSolveAttempt(CtfyModel):
914    """One archived instance for a challenge — used to visualise per-team
915    multi-solve attempts on the challenge detail page."""
916
917    instance_id: str
918    team_id: str = ""
919    team_name: str = ""
920    display_name: str = ""
921    challenge_id: str = ""
922    started_at: float = 0.0
923    stopped_at: float = 0.0
924    duration_s: float = 0.0
925    attempts: int = 0
926    solved: bool = False
927    solved_flags: list[str] = Field(default_factory=list)
928    stop_reason: str = ""
929    #: See :attr:`ScoreboardEntry.anonymous`.
930    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):
949class ChallengeSolveAttemptsResponse(CtfyModel):
950    challenge_id: str
951    attempts: list[ChallengeSolveAttempt] = Field(default_factory=list)
952    per_team: list[ChallengeTeamSolveSummary] = Field(default_factory=list)
953    # Total archived instances (incl. unsolved) — lets the UI show
954    # "showing N solved of M total" when the response is filtered.
955    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 ChallengeSourceCompetition(ctfy.core.models.CtfyModel):
2689class ChallengeSourceCompetition(CtfyModel):
2690    """One competition named on the challenge-source card (#2234)."""
2691
2692    id: str
2693    title: str
2694    #: The title per locale (#2230); a browser renders this.
2695    title_i18n: LocalizedText = Field(default_factory=dict)
2696    status: CompetitionStatus

One competition named on the challenge-source card (#2234).

id: str = PydanticUndefined
title: str = PydanticUndefined
title_i18n: dict[str, str] = PydanticUndefined
status: Literal['draft', 'published', 'archived'] = PydanticUndefined
class ChallengeTcpAccessRow(ctfy.core.models.CtfyModel):
540class ChallengeTcpAccessRow(CtfyModel):
541    """One challenge of a competition with a box: its override and its way in (#2082, #3291)."""
542
543    challenge_id: str
544    name: str = ""
545    #: An ``engagement`` estate: always reached through the VPN, whatever is
546    #: chosen, because a port or an SNI route reaches one service of it.
547    engagement: bool = False
548    #: A UDP service (#2680): never reached by TLS + SNI, so a ``tls_sni``
549    #: choice reads as a public port for it.
550    udp: bool = False
551    #: Web-only (#3291): no raw-TCP or UDP service. Reached on its domain
552    #: (``tls_sni``) unless the override is ``vpn``; the competition's
553    #: default never moves it, and ``public_port`` is refused for it.
554    web: bool = False
555    #: The organiser's per-challenge override, ``None`` when the challenge
556    #: follows the competition's ``tcp_access``.
557    override: TcpAccess | None = None
558    #: What an instance started here now gets.
559    tcp_access: TcpAccess = TcpAccess.TLS_SNI
560    #: What it would get following the competition's default — the label of
561    #: the select's "follow" option. Not always the default itself: a UDP
562    #: challenge under a ``tls_sni`` default follows to ``public_port``
563    #: (#2680), and the page must not name a method the row never gets.
564    follows: TcpAccess = TcpAccess.TLS_SNI

One challenge of a competition with a box: its override and its way in (#2082, #3291).

challenge_id: str = PydanticUndefined
name: str = ''
engagement: bool = False
udp: bool = False
web: bool = False
override: ctfy.core.enums.TcpAccess | None = None
tcp_access: ctfy.core.enums.TcpAccess = <TcpAccess.TLS_SNI: 'tls_sni'>
follows: ctfy.core.enums.TcpAccess = <TcpAccess.TLS_SNI: 'tls_sni'>
class ChallengeTeamDetail(ctfy.core.models.CtfyModel):
132class ChallengeTeamDetail(CtfyModel):
133    """Which teams played one challenge -- the organiser's always, the author's
134    only when the event's ``author_team_detail`` opens it (#2285)."""
135
136    #: May this reader see the rows. ``False`` = locked: ``rows`` is ``None``.
137    visible: bool
138    #: The event's setting, shown to an author so the locked panel can say what
139    #: opens it; ``None`` for an organiser (always open).
140    policy: AuthorTeamDetail | None = None
141    #: When an ``after_end`` detail opens; ``None`` otherwise.
142    opens_at: AwareDatetime | None = None
143    #: Whether any view was recorded for the event (the ``views`` columns).
144    views_recorded: bool = False
145    rows: list[ChallengeTeamRow] | None = None
146    #: Teams that did anything with the challenge, before the table's cap.
147    total: int | None = None

Which teams played one challenge -- the organiser's always, the author's only when the event's author_team_detail opens it (#2285).

visible: bool = PydanticUndefined
policy: Optional[Literal['off', 'after_end', 'always']] = None
opens_at: pydantic.types.AwareDatetime | None = None
views_recorded: bool = False
rows: list[ChallengeTeamRow] | None = None
total: int | None = None
class ChallengeTeamRow(ctfy.core.models.CtfyModel):
101class ChallengeTeamRow(CtfyModel):
102    """One team's dealings with one challenge (#2285).
103
104    ⚠️ ``team_name`` / ``anonymous`` are an entrant's identity: the doors that
105    publish this row run it through
106    :func:`ctfy.server.anonymity.anonymise_challenge_team_detail`, which aliases
107    a withheld squad or drops it, and an organiser sees through that as on every
108    other surface."""
109
110    team_id: str
111    team_name: str
112    #: ``True`` when ``team_name`` is a stand-in for a withheld squad.
113    anonymous: bool = False
114    entrant_kind: TeamKind
115    #: Days the team's people opened the challenge, summed; ``None`` when views
116    #: were not recorded for the event.
117    views: int | None = None
118    first_viewed_at: AwareDatetime | None = None
119    starts: int
120    first_started_at: AwareDatetime | None = None
121    #: Answers submitted, wrong ones included.
122    attempts: int
123    wrong: int
124    solved: bool
125    solved_at: AwareDatetime | None = None
126    #: First box to finish, seconds; ``None`` without a box or a finish.
127    time_to_solve_s: float | None = None
128    #: The chips the team gave the challenge.
129    reactions: list[Reaction] = Field(default_factory=list)

One team's dealings with one challenge (#2285).

⚠️ team_name / anonymous are an entrant's identity: the doors that publish this row run it through ctfy.server.anonymity.anonymise_challenge_team_detail(), which aliases a withheld squad or drops it, and an organiser sees through that as on every other surface.

team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
anonymous: bool = False
entrant_kind: Literal['human', 'agent'] = PydanticUndefined
views: int | None = None
first_viewed_at: pydantic.types.AwareDatetime | None = None
starts: int = PydanticUndefined
first_started_at: pydantic.types.AwareDatetime | None = None
attempts: int = PydanticUndefined
wrong: int = PydanticUndefined
solved: bool = PydanticUndefined
solved_at: pydantic.types.AwareDatetime | None = None
time_to_solve_s: float | None = None
reactions: list[typing.Literal['addictive', 'mindblown', 'learned', 'goat_setter', 'overthought', 'guessy', 'brutal', 'buggy', 'infra_broken']] = PydanticUndefined
class ChallengeTeamSolveSummary(ctfy.core.models.CtfyModel):
933class ChallengeTeamSolveSummary(CtfyModel):
934    """Per-team aggregate for the challenge detail solve-attempts panel."""
935
936    team_id: str
937    team_name: str = ""
938    display_name: str = ""
939    solve_count: int = 0
940    attempt_count: int = 0
941    best_duration_s: float = 0.0
942    total_duration_s: float = 0.0
943    #: See :attr:`ScoreboardEntry.anonymous`.
944    anonymous: bool = False
945    first_solved_at: float = 0.0
946    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):
1378class ChallengeValuePoint(CtfyModel):
1379    """One point on a challenge's decay curve.
1380
1381    ⚠️ ``solvers`` is **1-based**, matching the decay arithmetic
1382    everywhere else: the first team to capture sees the full base value
1383    under every curve, which is the property that makes the curves
1384    comparable to each other and to a static board.
1385    """
1386
1387    solvers: int = 0
1388    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 ChangedSinceVerdict(ctfy.core.models.CtfyModel):
416class ChangedSinceVerdict(CtfyModel):
417    """One answer that differs from what the last verdict saw (#2524).
418
419    Names the field and how it moved, never the value: the roster does not
420    widen who reads personal data. The timeline carries the values.
421    """
422
423    #: The entrant whose answer it is; ``None`` for a team-level field.
424    subject_user_id: str | None = None
425    #: ``real_name`` for an entrant's answer, ``team.description`` for the team's.
426    field: str
427    kind: ChangeKind

One answer that differs from what the last verdict saw (#2524).

Names the field and how it moved, never the value: the roster does not widen who reads personal data. The timeline carries the values.

subject_user_id: str | None = None
field: str = PydanticUndefined
kind: Literal['filled', 'changed', 'cleared'] = PydanticUndefined
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):
722class CheckAnswerRequest(CtfyModel):
723    """Run the checker for one *checker-mode* question on an instance.
724
725    Proof-of-exploitation: the platform execs the question's
726    author-supplied checker inside the trusted judge sidecar; the player
727    submits no answer string — the sidecar emits the minted token only
728    when the exploit's effect is verified.
729    """
730
731    # Which checker question to verify. Checker questions usually carry a
732    # descriptive id (``"deface"`` / ``"persist"`` / …); defaults to
733    # ``"flag"`` for the single-question shape.
734    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):
737class CheckAnswerResponse(CtfyModel):
738    # True when the checker confirmed the exploit (its stdout matched the
739    # instance's minted token for this question) and a solve was recorded.
740    passed: bool
741    # Echoes the question id on a pass; ``None`` otherwise.
742    question_id: str | None = None
743    # Checker process exit code — diagnostic only; the token match is the
744    # real signal. ``None`` when the checker couldn't be run at all.
745    exit_code: int | None = None
746    # True when the checker exceeded its wall-clock budget.
747    timed_out: bool = False
748    # True when the team had already solved this question (short-circuit,
749    # no node call made).
750    already_solved: bool = False
751    # First-blood rank on a fresh solve (1/2/3 = 1st/2nd/3rd blood), 0
752    # otherwise. Mirrors ``SubmissionResponse.solve_rank``.
753    solve_rank: int = 0
754    # True when this check completed the challenge (every question solved).
755    challenge_fully_solved: bool = False
756    # Server clock (epoch seconds) when the check ran, for the UI's
757    # "last checked" line.
758    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):
1107class ClusterInfo(CtfyModel):
1108    """Returned by ``GET /cluster-info``.
1109
1110    Provides the bits the ``ctfy-admin server invite`` CLI and admin UI wizard
1111    need to build a runnable join command. The *registration token* is
1112    minted separately via ``POST /nodes/invites`` — it isn't on this
1113    payload because cluster info is queried speculatively by the UI on
1114    every admin dashboard load, and we never want to hand an invite
1115    token to a page that didn't explicitly ask for one.
1116    """
1117
1118    platform_url: str
1119    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 CompetitionAdminGrantRequest(ctfy.server.models.GrantCompetitionAdminRequest):
3040class CompetitionAdminGrantRequest(GrantCompetitionAdminRequest):
3041    """Body for ``POST /admin/competitions/{id}/admins``: the shared body
3042    plus when the grant lapses (#2429 b). ``None`` is a grant that never
3043    lapses, as every grant before this field. Must be in the future: a
3044    grant already lapsed when written would be a revoke under another name
3045    (``400 grant_expiry_in_past``). Re-granting writes a new value, so a
3046    renewal is the same call."""
3047
3048    expires_at: AwareDatetime | None = None

Body for POST /admin/competitions/{id}/admins: the shared body plus when the grant lapses (#2429 b). None is a grant that never lapses, as every grant before this field. Must be in the future: a grant already lapsed when written would be a revoke under another name (400 grant_expiry_in_past). Re-granting writes a new value, so a renewal is the same call.

expires_at: pydantic.types.AwareDatetime | None = None
class CompetitionAdminInfo(ctfy.core.models.CtfyModel):
2971class CompetitionAdminInfo(CtfyModel):
2972    """One per-competition admin grant, for the super-admin management
2973    card on the competition detail page."""
2974
2975    user_id: str
2976    display_name: str = ""
2977    email: str = ""
2978    role: CompetitionGrantRole = "admin"
2979    granted_by: str = ""
2980    granted_at: datetime | None = None
2981    #: When the grant lapses (#2429 b); ``None`` = never.
2982    expires_at: datetime | None = None
2983    #: The server's verdict, ``core.competition_grants.grant_in_force`` at
2984    #: read time. ``False`` = lapsed: listed so it can be revoked or
2985    #: renewed, and acting as no grant everywhere else.
2986    in_force: bool = True

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
expires_at: datetime.datetime | None = None
in_force: bool = True
class CompetitionApplicationInfo(ctfy.core.models.CtfyModel):
3185class CompetitionApplicationInfo(CtfyModel):
3186    """The caller's own application to one competition (plan L2).
3187
3188    ⚠️ **No ``decided_by``.** The applicant is told the verdict and the
3189    note, never which organiser ruled: a name on a rejection is somebody
3190    to argue with, and the organisers answer as the event.
3191    """
3192
3193    competition_id: str
3194    message: str = ""
3195    status: ReviewStatus = "pending"
3196    created_at: datetime
3197    #: When an organiser ruled — ``None`` while pending.
3198    decided_at: datetime | None = None
3199    #: What the organisers wrote with a rejection. ``""`` is none.
3200    decision_note: str = ""
3201    #: The applicant's own answers (#2306); ``""`` on an application from
3202    #: before they were asked.
3203    organization: str = ""
3204    purpose: ApplicationPurpose | Literal[""] = ""
3205    purpose_detail: str = ""
3206    contact_email: 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 = ''
organization: str = ''
purpose: Union[Literal['vendor_evaluation', 'academic_research', 'teaching', 'other'], Literal['']] = ''
purpose_detail: str = ''
contact_email: str = ''
class CompetitionApplicationRequest(ctfy.core.models.CtfyModel):
3165class CompetitionApplicationRequest(CtfyModel):
3166    """Body for ``PUT /competitions/{id}/application`` (plan L2).
3167
3168    ``message`` is what the organisers read in their queue — who you are,
3169    why this event. Optional: the application itself is the request.
3170    """
3171
3172    message: str = Field(default="", max_length=APPLICATION_MESSAGE_MAX)
3173    #: The applicant's organisation (#2306). Required: blank after trimming
3174    #: is ``application_organization_required``.
3175    organization: str = Field(max_length=APPLICATION_ORGANIZATION_MAX)
3176    #: Why they are asking — one of four, read by the organiser first.
3177    purpose: ApplicationPurpose
3178    #: More about the purpose; required when ``purpose`` is ``other``
3179    #: (``application_purpose_detail_required``).
3180    purpose_detail: str = Field(default="", max_length=APPLICATION_MESSAGE_MAX)
3181    #: Where the organisers may reply. Sealed at rest.
3182    contact_email: EmailStr

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 = ''
organization: str = PydanticUndefined
purpose: Literal['vendor_evaluation', 'academic_research', 'teaching', 'other'] = PydanticUndefined
purpose_detail: str = ''
contact_email: pydantic.networks.EmailStr = PydanticUndefined
class CompetitionApplicationReviewRequest(ctfy.core.models.CtfyModel):
3245class CompetitionApplicationReviewRequest(CtfyModel):
3246    """Body for ``POST /admin/competitions/{id}/applications/{user_id}/review``.
3247
3248    ``note`` goes to the applicant with a rejection, and a rejection
3249    needs one — it is final, and "no" with nothing beside it is a support
3250    ticket. An approval takes none: its answer is the invitation mail.
3251    """
3252
3253    status: ApplicationVerdict
3254    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):
3209class CompetitionApplicationRow(CtfyModel):
3210    """One application in an event's queue, for its organisers (plan L2).
3211
3212    ``display_name`` / ``email`` are the invitations roster's pair,
3213    because the reader is deciding whether to let this person in.
3214    """
3215
3216    competition_id: str
3217    user_id: str
3218    display_name: str = ""
3219    email: str = ""
3220    message: str = ""
3221    status: ReviewStatus = "pending"
3222    created_at: datetime
3223    decided_by: str = ""
3224    #: Resolved from :attr:`decided_by`, because the raw id names nobody.
3225    #: ``""`` while pending, when the organiser's account is gone, and
3226    #: when an address the person proved answered it (the claim has no
3227    #: organiser of its own beyond the one who invited the address).
3228    decided_by_name: str = ""
3229    decided_at: datetime | None = None
3230    decision_note: str = ""
3231    #: Whether the person holds an invitation **now**. Distinct from
3232    #: ``status == "approved"``: an invitation revoked after approval
3233    #: leaves the verdict standing and this false, and a rejected
3234    #: applicant invited directly reads ``rejected`` beside ``True``.
3235    invited: bool = False
3236    #: Who is asking and why (#2306) — ``""`` on an application from before
3237    #: the form asked. ``contact_email`` is where the applicant asked to be
3238    #: answered, which may differ from the account's ``email``.
3239    organization: str = ""
3240    purpose: ApplicationPurpose | Literal[""] = ""
3241    purpose_detail: str = ""
3242    contact_email: str = ""

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
organization: str = ''
purpose: Union[Literal['vendor_evaluation', 'academic_research', 'teaching', 'other'], Literal['']] = ''
purpose_detail: str = ''
contact_email: str = ''
class CompetitionAttachmentInfo(ctfy.core.models.CtfyModel):
2491class CompetitionAttachmentInfo(CtfyModel):
2492    """One file hung on a competition — its rules, handbook or notice (#1808).
2493
2494    ⚠️ **No bytes and no object key.** The bytes are fetched from
2495    ``url``, proxied through the platform behind the competition's own
2496    visibility — the key is exactly the guess that proxying exists to
2497    make useless.
2498    """
2499
2500    id: str
2501    competition_id: str
2502    #: Sanitised, display only — the object key is derived from ``id``.
2503    filename: str
2504    #: Sniffed from the bytes. An ``image/*`` type renders inline; every
2505    #: other type is a download.
2506    content_type: str
2507    size_bytes: int
2508    #: ``""`` shows to every reader; a locale tag shows only to readers of
2509    #: that language. The browser filters — the language is a client-side
2510    #: preference switched without a reload.
2511    locale: str = ""
2512    #: Optional display title per locale; ``{}`` means show ``filename``.
2513    title: LocalizedText = Field(default_factory=dict)
2514    position: int = 0
2515    uploaded_at: datetime | None = None
2516
2517    @classmethod
2518    def of(cls, row: CompetitionAttachmentState) -> CompetitionAttachmentInfo:
2519        """The projection of a stored row — one spelling for every reader
2520        (the list routes, the detail payload), so none drops a field."""
2521        return cls(
2522            id=row.id,
2523            competition_id=row.competition_id,
2524            filename=row.filename,
2525            content_type=row.content_type,
2526            size_bytes=row.size_bytes,
2527            locale=row.locale,
2528            title=row.title,
2529            position=row.position,
2530            uploaded_at=row.uploaded_at,
2531        )
2532
2533    @computed_field
2534    @property
2535    def url(self) -> str:
2536        """The stable address of the bytes, for linking from the handbook
2537        markdown: ``/api/v1/competitions/{id}/attachments/{attachment_id}``.
2538        Derived from the two ids, so it cannot disagree with the route."""
2539        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:
2517    @classmethod
2518    def of(cls, row: CompetitionAttachmentState) -> CompetitionAttachmentInfo:
2519        """The projection of a stored row — one spelling for every reader
2520        (the list routes, the detail payload), so none drops a field."""
2521        return cls(
2522            id=row.id,
2523            competition_id=row.competition_id,
2524            filename=row.filename,
2525            content_type=row.content_type,
2526            size_bytes=row.size_bytes,
2527            locale=row.locale,
2528            title=row.title,
2529            position=row.position,
2530            uploaded_at=row.uploaded_at,
2531        )

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

url: str
2533    @computed_field
2534    @property
2535    def url(self) -> str:
2536        """The stable address of the bytes, for linking from the handbook
2537        markdown: ``/api/v1/competitions/{id}/attachments/{attachment_id}``.
2538        Derived from the two ids, so it cannot disagree with the route."""
2539        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):
2571class CompetitionAttachmentUpdate(CtfyModel):
2572    """An organiser's edit to one attachment. Partial: an omitted field is
2573    left alone (``model_fields_set``), so re-titling does not reset the
2574    locale — the defect the token editor already paid for."""
2575
2576    #: ``{}`` clears the title back to the filename.
2577    title: LocalizedText | None = None
2578    locale: str | None = None
2579    position: int | None = Field(default=None, ge=0, le=100_000)
2580
2581    _v_title = field_validator("title", mode="before")(
2582        lambda v: localized_field(v, cap=MAX_ATTACHMENT_TITLE_CHARS, field="title")
2583    )
2584    _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):
2542class CompetitionAttachmentsResponse(CtfyModel):
2543    """A competition's files, in display order — every locale's.
2544
2545    ⚠️ **Unfiltered by language on purpose.** Which files a reader sees
2546    is their language, a browser preference switched without a reload;
2547    a server-side filter would follow ``Accept-Language`` and not the
2548    toggle — ``resolve_localized``'s reason for resolving in the client.
2549    """
2550
2551    competition_id: str
2552    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 CompetitionBanRequest(ctfy.core.models.CtfyModel):
625class CompetitionBanRequest(CtfyModel):
626    """Body for ``POST /admin/competitions/{id}/bans`` (#2068).
627
628    Names a person (``user_id``) **or** a team (``team_id``), exactly one.
629    Banning a person removes them from their team and bars them from every
630    door that puts a person on one. Banning a team disqualifies it, then bars
631    **and removes** every current member (#2623): the team stays as the
632    empty, disqualified record of what happened. There is no switch for the
633    removal -- a ``dissolve`` flag the UI never sent is how "移出并禁赛" came
634    to remove nobody. A client still sending it is ignored.
635    """
636
637    user_id: str = ""
638    team_id: str = ""
639    # Same floor as a disqualification: this is the row an appeal is
640    # argued from and the text the person is shown.
641    reason: str = Field(min_length=10, max_length=2000)
642    case_id: str = ""
643    #: Tell the team (banner + mail). Off = a silent action: recorded, but the
644    #: team sees nothing and so has nothing to appeal. Unset is on for a new
645    #: ban; for a ban of a team whose ruling stands (a correction) unset keeps
646    #: the ruling as it is, and only an explicit ``true`` announces a silent
647    #: one (#2623) -- a client that never sends the field must not announce
648    #: what the risk page made silent.
649    notify_team: bool | None = None

Body for POST /admin/competitions/{id}/bans (#2068).

Names a person (user_id) or a team (team_id), exactly one. Banning a person removes them from their team and bars them from every door that puts a person on one. Banning a team disqualifies it, then bars and removes every current member (#2623): the team stays as the empty, disqualified record of what happened. There is no switch for the removal -- a dissolve flag the UI never sent is how "移出并禁赛" came to remove nobody. A client still sending it is ignored.

user_id: str = ''
team_id: str = ''
reason: str = PydanticUndefined
case_id: str = ''
notify_team: bool | None = None
class CompetitionBanRow(ctfy.core.models.CtfyModel):
652class CompetitionBanRow(CtfyModel):
653    """One ledger row, with the names an organiser reads it by."""
654
655    user_id: str
656    user_display_name: str = ""
657    competition_id: str
658    reason: str = ""
659    banned_by: str = ""
660    banned_by_display_name: str = ""
661    banned_at: datetime | None = None
662    team_id: str = ""
663    team_name: str = ""
664    active: bool = True
665    lifted_by: str = ""
666    lifted_by_display_name: str = ""
667    lifted_at: datetime | None = None

One ledger row, with the names an organiser reads it by.

user_id: str = PydanticUndefined
user_display_name: str = ''
competition_id: str = PydanticUndefined
reason: str = ''
banned_by: str = ''
banned_by_display_name: str = ''
banned_at: datetime.datetime | None = None
team_id: str = ''
team_name: str = ''
active: bool = True
lifted_by: str = ''
lifted_by_display_name: str = ''
lifted_at: datetime.datetime | None = None
class CompetitionBoardCreate(ctfy.core.models.CtfyModel):
2849class CompetitionBoardCreate(CtfyModel):
2850    """An organiser's new board. It starts ``shadow`` unless the request
2851    says otherwise: a board is tried on the real facts before anybody sees
2852    it, and the default publishes nothing."""
2853
2854    slug: str
2855    title: LocalizedText = Field(default_factory=dict)
2856    rule: ScoringRuleName
2857    params: dict[str, Any] = Field(default_factory=dict)
2858    visibility: BoardVisibility = "shadow"
2859    position: int = Field(default=0, ge=0, le=1000)
2860
2861    _v_title = field_validator("title", mode="before")(
2862        lambda v: localized_field(v, cap=MAX_BOARD_TITLE_CHARS, field="title")
2863    )

An organiser's new board. It starts shadow unless the request says otherwise: a board is tried on the real facts before anybody sees it, and the default publishes nothing.

slug: str = PydanticUndefined
title: dict[str, str] = PydanticUndefined
rule: Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring', 'authoring'] = PydanticUndefined
params: dict[str, typing.Any] = PydanticUndefined
visibility: Literal['published', 'shadow'] = 'shadow'
position: int = 0
class CompetitionBoardInfo(ctfy.core.models.CtfyModel):
2812class CompetitionBoardInfo(CtfyModel):
2813    """One of a competition's boards (#1965 G): a named ``(rule, params)``.
2814
2815    ``slug`` is what ``GET /competitions/{id}/scoreboard?board=`` takes. The
2816    primary board — the competition's own rule, the one its rank chip, its
2817    histogram and its score history read — is always listed first with
2818    ``primary: true`` and slug ``main``; ``?board=`` empty means it too.
2819
2820    ⚠️ ``visibility`` is ``shadow`` only on an organiser's read: a shadow
2821    board is an organiser's trial on the event's real facts, and to anybody
2822    else it does not exist (``404 competition_board_not_found``).
2823    """
2824
2825    slug: str
2826    #: ``{locale: text}``; empty means "name it after its rule".
2827    title: LocalizedText = Field(default_factory=dict)
2828    rule: ScoringRuleName = "flag_count"
2829    params: dict[str, Any] = Field(default_factory=dict)
2830    visibility: BoardVisibility = "published"
2831    primary: bool = False
2832    position: int = 0
2833    #: What this board ranks by, in order — its rule's own ``order``, the
2834    #: same projection as ``CompetitionInfo.ordering`` (the primary's).
2835    ordering: list[BoardCriterion] = Field(default_factory=list)

One of a competition's boards (#1965 G): a named (rule, params).

slug is what GET /competitions/{id}/scoreboard?board= takes. The primary board — the competition's own rule, the one its rank chip, its histogram and its score history read — is always listed first with primary: true and slug main; ?board= empty means it too.

⚠️ visibility is shadow only on an organiser's read: a shadow board is an organiser's trial on the event's real facts, and to anybody else it does not exist (404 competition_board_not_found).

slug: str = PydanticUndefined
title: dict[str, str] = PydanticUndefined
rule: Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring', 'authoring'] = 'flag_count'
params: dict[str, typing.Any] = PydanticUndefined
visibility: Literal['published', 'shadow'] = 'published'
primary: bool = False
position: int = 0
ordering: list[BoardCriterion] = PydanticUndefined
class CompetitionBoardUpdate(ctfy.core.models.CtfyModel):
2866class CompetitionBoardUpdate(CtfyModel):
2867    """An organiser's edit to one board. Partial (``model_fields_set``): an
2868    omitted field is left alone. ⚠️ ``rule`` and ``params`` together are the
2869    board's scoring; ``params`` without ``rule`` is checked against the
2870    board's current rule."""
2871
2872    title: LocalizedText | None = None
2873    rule: ScoringRuleName | None = None
2874    params: dict[str, Any] | None = None
2875    visibility: BoardVisibility | None = None
2876    position: int | None = Field(default=None, ge=0, le=1000)
2877    #: Why, when the change is to a published board's scoring while the
2878    #: competition is live (#1965 H): required then, recorded and shown on
2879    #: the board. Ignored for anything else.
2880    scoring_change_reason: str | None = Field(default=None, max_length=2000)
2881
2882    _v_title = field_validator("title", mode="before")(
2883        lambda v: localized_field(v, cap=MAX_BOARD_TITLE_CHARS, field="title")
2884    )

An organiser's edit to one board. Partial (model_fields_set): an omitted field is left alone. ⚠️ rule and params together are the board's scoring; params without rule is checked against the board's current rule.

title: dict[str, str] | None = None
rule: Optional[Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring', 'authoring']] = None
params: dict[str, typing.Any] | None = None
visibility: Optional[Literal['published', 'shadow']] = None
position: int | None = None
scoring_change_reason: str | None = None
class CompetitionBoardsResponse(ctfy.core.models.CtfyModel):
2838class CompetitionBoardsResponse(CtfyModel):
2839    """A competition's boards as this caller may read them: the primary
2840    first, then the extras in tab order. The whole set — there are at most
2841    ``core.competition_boards.MAX_COMPETITION_BOARDS`` extras — so it is not
2842    paged and ``total`` is ``len(boards)``."""
2843
2844    competition_id: str
2845    boards: list[CompetitionBoardInfo] = Field(default_factory=list)
2846    total: int = 0

A competition's boards as this caller may read them: the primary first, then the extras in tab order. The whole set — there are at most core.competition_boards.MAX_COMPETITION_BOARDS extras — so it is not paged and total is len(boards).

competition_id: str = PydanticUndefined
boards: list[CompetitionBoardInfo] = PydanticUndefined
total: int = 0
class CompetitionChallengeBreakdown(ctfy.core.models.CtfyModel):
1453class CompetitionChallengeBreakdown(CtfyModel):
1454    challenges: list[CompetitionChallengeRow] = Field(default_factory=list)
1455    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 CompetitionChallengeChangeInfo(ctfy.core.models.CtfyModel):
685class CompetitionChallengeChangeInfo(CtfyModel):
686    """A challenge added to or taken out of a locked event in play (#2041)."""
687
688    id: str
689    competition_id: str
690    #: ``""`` for a change of ``challenge_scope`` (``action`` ``scope_*``).
691    challenge_id: str
692    action: ChallengeChangeAction
693    #: The organiser's own words — or, for a change the platform made itself,
694    #: the English of ``reason_i18n``.
695    reason: str
696    #: Set when the platform made the change itself (#2553 — an
697    #: invitation-only event's upload left this set, its solves' points
698    #: kept); ``None`` for a person's change, whose ``reason`` is their own.
699    reason_code: PlatformChangeReason | None = None
700    #: ``reason_code``'s sentence in every locale the platform stores copy in
701    #: (``ctfy.core.localized.LOCALES``) — it travels with the row, so every
702    #: reader shows the reader's language. Empty for a person's change.
703    reason_i18n: dict[str, str] = Field(default_factory=dict)
704    changed_by: str
705    #: What to call ``changed_by`` (``operator_label``).
706    changed_by_display_name: str = ""
707    changed_at: AwareDatetime

A challenge added to or taken out of a locked event in play (#2041).

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
action: Literal['added', 'removed', 'scope_set', 'scope_catalog'] = PydanticUndefined
reason: str = PydanticUndefined
reason_code: Optional[Literal['upload_stays_home', 'upload_stays_home_unranked', 'upload_back_in_set', 'upload_retired', 'upload_retired_unranked', 'upload_retired_scores_removed', 'upload_blocked', 'upload_blocked_unranked', 'upload_blocked_scores_removed']] = None
reason_i18n: dict[str, str] = PydanticUndefined
changed_by: str = PydanticUndefined
changed_by_display_name: str = ''
changed_at: pydantic.types.AwareDatetime = PydanticUndefined
class CompetitionChallengeOrder(ctfy.core.models.CtfyModel):
3305class CompetitionChallengeOrder(CtfyModel):
3306    """A competition's curated challenges in the order its entrants see them
3307    (#2037): exactly the set it holds, reordered."""
3308
3309    challenge_ids: list[str] = Field(default_factory=list, max_length=2000)

A competition's curated challenges in the order its entrants see them (#2037): exactly the set it holds, reordered.

challenge_ids: list[str] = PydanticUndefined
class CompetitionChallengeRow(ctfy.core.models.CtfyModel):
1391class CompetitionChallengeRow(CtfyModel):
1392    """Per-challenge competition aggregate: solve count, attempt
1393    success rate, and first blood. Reconstructed from the solve log
1394    with the same window / team filter as the scoreboard so the
1395    numbers always agree with the leaderboard."""
1396
1397    challenge_id: str
1398    name: str = ""
1399    category: ChallengeCategory | None = None
1400    difficulty: str = ""
1401    # Teams that captured every declared flag (matches scoreboard's
1402    # ``solved`` tally).
1403    solves_count: int = 0
1404    # In-window submissions for this challenge across registered teams.
1405    attempts: int = 0
1406    success_rate: float = 0.0
1407    # What this challenge is worth **right now**, under this competition's
1408    # own scoring rule, at the solver count above.
1409    #
1410    # ⚠️ **``None`` means the board does not price challenges, and it is
1411    # not ``0``.** On a ``flag_count`` board the warm-up and the last
1412    # link of a five-stage chain are both worth one flag, so there is no
1413    # price to publish — and a zero would read as "this challenge is
1414    # worthless", a claim about the competition nobody made. The panel
1415    # that reads this is meant to be *absent* there, not full of zeroes.
1416    #
1417    # ⚠️ Server-composed for the reason every axis on `CompetitionInfo`
1418    # is: the arithmetic is ``PointsRule._decayed``, and a client that
1419    # recomputed decay would be the copy a reader is looking at when the
1420    # two disagree — on the one board whose whole point is that the
1421    # numbers move while nobody solves anything.
1422    value: int | None = None
1423    # The same challenge's value at one solver, so a panel can show how far
1424    # it has fallen without re-deriving the curve. ⚠️ Absent on the same
1425    # terms as ``value``: two fields, one question.
1426    base_value: int | None = None
1427    #: ``(solvers, value)`` from the first capture until the price stops
1428    #: moving — the shape between ``base_value`` and ``value``.
1429    #:
1430    #: ⚠️ **Sampled through the scoring rule, never re-derived.** Drawing
1431    #: this from the decay config in a panel would be a second
1432    #: implementation of ``PointsRule._decayed`` and the copy a reader is
1433    #: looking at when the two disagree.
1434    #:
1435    #: ⚠️ **Empty on a board that does not price**, matching ``value``'s
1436    #: ``None`` — never a flat line at zero, which would be a price
1437    #: nobody set. A board with no *decay* is a flat line at the base,
1438    #: which is two points rather than none.
1439    decay: list[ChallengeValuePoint] = Field(default_factory=list)
1440    # First team to solve this challenge in-window. Empty when unsolved.
1441    first_blood_team_id: str = ""
1442    first_blood_team_name: str = ""
1443    first_blood_at: datetime | None = None
1444    # ⚠️ Named for the *attribution*, not for the row: every other field
1445    # here is a fact about the challenge, and a bare ``anonymous`` on
1446    # this model would read as "this challenge is anonymous". Empty
1447    # ``first_blood_team_id`` with this ``False`` is an unsolved
1448    # challenge; empty with it ``True`` would be a contradiction, so the
1449    # hidden case blanks the trio and leaves this alone.
1450    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 CompetitionChallengeSchedule(ctfy.core.models.CtfyModel):
2677class CompetitionChallengeSchedule(CtfyModel):
2678    """The organiser's view of a competition's per-challenge release (#2093):
2679    every challenge of its set in the order entrants see them, with the
2680    schedule each carries."""
2681
2682    competition_id: str
2683    challenges: list[ChallengeScheduleRow] = Field(default_factory=list)
2684    #: The batch labels in use, sorted — the choices a "release this wave"
2685    #: control offers.
2686    batches: list[str] = Field(default_factory=list)

The organiser's view of a competition's per-challenge release (#2093): every challenge of its set in the order entrants see them, with the schedule each carries.

competition_id: str = PydanticUndefined
challenges: list[ChallengeScheduleRow] = PydanticUndefined
batches: list[str] = PydanticUndefined
class CompetitionChallengeSource(ctfy.core.models.CtfyModel):
2699class CompetitionChallengeSource(CtfyModel):
2700    """Where a competition's challenge set comes from, for its organisers
2701    (#2234): the competition it follows, and the competitions following it.
2702
2703    Organiser-only, like the schedule: to a player a follower's challenges
2704    are simply its challenges."""
2705
2706    competition_id: str
2707    #: The competition this one follows; ``None`` when it follows none. A
2708    #: source this organiser may not see is named by its id alone, with
2709    #: ``title`` empty.
2710    source: ChallengeSourceCompetition | None = None
2711    #: Whether the source is passing its set on now — ``False`` while the
2712    #: source is not published (the follower then serves nothing).
2713    source_serves: bool = False
2714    #: How many challenges the follower serves through the link now.
2715    inherited_count: int = 0
2716    #: How many of the source's challenges are player uploads, which a
2717    #: follower never inherits.
2718    excluded_upload_count: int = 0
2719    #: The competitions that follow this one, by id.
2720    followers: list[ChallengeSourceCompetition] = Field(default_factory=list)

Where a competition's challenge set comes from, for its organisers (#2234): the competition it follows, and the competitions following it.

Organiser-only, like the schedule: to a player a follower's challenges are simply its challenges.

competition_id: str = PydanticUndefined
source: ChallengeSourceCompetition | None = None
source_serves: bool = False
inherited_count: int = 0
excluded_upload_count: int = 0
followers: list[ChallengeSourceCompetition] = PydanticUndefined
class CompetitionCloneRequest(ctfy.core.models.CtfyModel):
628class CompetitionCloneRequest(CtfyModel):
629    """``POST /admin/competitions/{id}/clone`` — clone the event as its test (#2041).
630
631    ``as_test`` must be true: a test is the only clone the platform makes,
632    because "a copy with every rule" and "a copy for rehearsing" are the
633    same act, and a general-purpose copy would need its own allow-list of
634    what differs. ``starts_at`` moves every window of the official event —
635    its sessions, registration and authoring windows, the match's kickoff
636    and the topics' release — by one offset, so the test keeps the event's
637    shape; absent, every window is cleared.
638    """
639
640    as_test: bool = True
641    starts_at: AwareDatetime | None = None

POST /admin/competitions/{id}/clone — clone the event as its test (#2041).

as_test must be true: a test is the only clone the platform makes, because "a copy with every rule" and "a copy for rehearsing" are the same act, and a general-purpose copy would need its own allow-list of what differs. starts_at moves every window of the official event — its sessions, registration and authoring windows, the match's kickoff and the topics' release — by one offset, so the test keeps the event's shape; absent, every window is cleared.

as_test: bool = True
starts_at: pydantic.types.AwareDatetime | None = None
class CompetitionCreate(ctfy.core.models.CtfyModel):
 918class CompetitionCreate(CtfyModel):
 919    """Request body for ``POST /admin/competitions``.
 920
 921    Time fields accept ISO 8601 strings; empty means "no bound" (live
 922    immediately / never expires). ``challenge_ids`` is validated against
 923    the spec registry inside the route handler — unknown ids return 422.
 924    """
 925
 926    #: The title as one string — what every caller sent before titles
 927    #: were localised (#2230). Filed by ``from_untagged_title`` when
 928    #: :attr:`title_i18n` is empty, ignored when it is not.
 929    title: str = Field(default="", max_length=MAX_COMPETITION_TITLE_CHARS)
 930    #: ``{locale: text}`` — the canonical title. At least one locale must
 931    #: have text, between this and ``title``.
 932    title_i18n: LocalizedText = Field(default_factory=dict)
 933
 934    _v_title_i18n = field_validator("title_i18n", mode="before")(title_map_field)
 935
 936    @model_validator(mode="after")
 937    def _one_title(self) -> CompetitionCreate:
 938        if not self.title_i18n:
 939            self.title_i18n = from_untagged_title(self.title)
 940        if not self.title_i18n:
 941            raise ValueError("title: give the title in at least one of en / zh-CN")
 942        return self
 943
 944    #: ``{locale: text}``; a bare string is read as the base locale.
 945    description: LocalizedText = Field(default_factory=dict)
 946    #: The organiser's long-form rules page, markdown. See
 947    #: ``CompetitionState.handbook`` — ``description`` stays the blurb
 948    #: its four render sites truncate, this is the document.
 949    handbook: LocalizedText = Field(default_factory=dict)
 950
 951    _v_description = field_validator("description", mode="before")(
 952        lambda v: localized_field(v, cap=MAX_DESCRIPTION_CHARS, field="description")
 953    )
 954    _v_handbook = field_validator("handbook", mode="before")(
 955        lambda v: localized_field(v, cap=MAX_HANDBOOK_CHARS, field="handbook")
 956    )
 957    starts_at: datetime | None = None
 958    ends_at: datetime | None = None
 959    # No fixed length cap: the real bound is ``_check_challenge_ids`` in
 960    # ``admin_competitions`` (every id must be a known spec, so a valid list
 961    # can never exceed the catalog). A magic number here is arbitrary and a
 962    # recurring foot-gun as the corpus grows — bucket comps already exceed 1000.
 963    challenge_ids: list[str] = Field(default_factory=list)
 964    #: What the competition lets its entrants play (#2553): ``set`` — the
 965    #: challenges above and nothing else (an empty set holds nothing, which
 966    #: is what the wizard has always said); ``catalog`` — any catalog
 967    #: challenge may start there. See ``CompetitionState.challenge_scope``.
 968    challenge_scope: ChallengeScope = "set"
 969    # New competitions start hidden ("draft") so admins can prepare them
 970    # privately and publish when ready.
 971    status: CompetitionStatus = "draft"
 972    # Participation access (orthogonal to ``status``). ``public`` is open
 973    # to everyone; ``private_listed`` / ``private_hidden`` require an admin
 974    # invite to register (the latter also hides the comp from non-invited
 975    # users). Defaults to ``public`` so ad-hoc API callers stay open.
 976    access: CompetitionAccess = "public"
 977    # A hosted event or a benchmark suite (#2097). ⚠️ Defaults ``event``, the
 978    # conservative claim: a suite is exempt from registration and the event
 979    # rules, so a caller who says nothing must not get one.
 980    kind: CompetitionKind = CompetitionKind.EVENT
 981    # The catalog directory a **suite** reads live (``XBOW``), ``""`` = a
 982    # curated ``challenge_ids`` list as for any event (#2097). ⚠️ Create-only:
 983    # the link is what the set *is*, so there is no PATCH that moves a suite
 984    # onto another directory. Refused without ``kind="suite"``: an event over
 985    # a directory would be the nineteen-benchmarks-on-the-contest-list state
 986    # this axis exists to end.
 987    bucket: str = ""
 988    # Marks this competition as an operator-curated eval **standard set** —
 989    # only such a competition may back an ``official`` EvalCampaign
 990    # (docs/model-eval-vendor-tenancy.md §4.4). ``corpus_sha`` optionally pins
 991    # the benchmark-corpus snapshot it was certified against. Off by default.
 992    eval_standard: bool = False
 993    corpus_sha: str = ""
 994    source_name: str = Field(default="", max_length=200)
 995    #: The suite's provenance link, drawn as an ``<a href>``: ``WebUrl``, the
 996    #: one allow-list for that, never a check of this model's own (#3215).
 997    source_url: WebUrl = Field(default="", max_length=2048)
 998    license: str = Field(default="", max_length=200)
 999    maintainer: str = Field(default="", max_length=200)
1000    version_date: str = Field(default="", max_length=10)
1001
1002    @field_validator("version_date")
1003    @classmethod
1004    def _version_date(cls, value: str | None) -> str | None:
1005        if value and date.fromisoformat(value).isoformat() != value:
1006            raise ValueError("version_date must be YYYY-MM-DD")
1007        return value
1008
1009    # May a user enter their own agent here? Its own axis, deliberately
1010    # not ``eval_standard`` above — that one means "a curated,
1011    # reproducible standard set", which is a claim about the challenge
1012    # list, not about who may play. ⚠️ Defaults **open**, unlike every
1013    # other marker on this model: an API caller who says nothing gets a
1014    # competition agents may enter. See CompetitionState.agents_allowed.
1015    agents_allowed: bool = True
1016    # Do entrants write challenges for each other here? ⚠️ Defaults
1017    # **off**, unlike ``agents_allowed`` directly above and for the
1018    # opposite reason: that one says who may enter an event, this one
1019    # opens an upload door, and a caller who says nothing must not get
1020    # one. See CompetitionState.authoring_enabled.
1021    authoring_enabled: bool = False
1022    # Does this event's wall draw its agents' model traffic? ⚠️
1023    # ``False`` for a caller who says nothing, `authoring_enabled`'s
1024    # reasoning rather than `agents_allowed`'s: this one opens a panel
1025    # on a *public, tokenless* projector page, so silence must not open
1026    # it. See CompetitionState.agent_pulse.
1027    agent_pulse: bool = False
1028    # Is the field of entrants dispatched agents, not humans — an agent
1029    # exhibition? ⚠️ Defaults **off**, ``authoring_enabled``'s reasoning
1030    # rather than ``agents_allowed``'s: this closes the human sign-up
1031    # door, so a caller who says nothing must get an ordinary,
1032    # human-registerable event. See CompetitionState.entrants_are_agents.
1033    entrants_are_agents: bool = False
1034    # The periods uploads are taken in, distinct from both the play window
1035    # and the registration one; empty means "for as long as the event
1036    # runs". `core.competitions.authoring_phase` is the rule.
1037    authoring_windows: list[AuthoringWindowInfo] = Field(default_factory=list)
1038    # Uploads one team may have awaiting review here; ``0`` defers to the
1039    # platform's ``max_pending_authored_challenges``, ``max_team_size``'s
1040    # shape. See CompetitionState.authoring_pending_per_team.
1041    authoring_pending_per_team: int = Field(default=0, ge=0)
1042    # Environments one team may throw away per challenge here (a reset,
1043    # or a stop then a fresh start); ``0`` defers to the platform's
1044    # ``max_resets_per_challenge``. See
1045    # CompetitionState.max_rebuilds_per_challenge.
1046    max_rebuilds_per_challenge: int = Field(default=0, ge=0, le=50)
1047    # Instances one team may hold at once here; ``0`` defers to the
1048    # platform's ``max_instances_per_team``, which is also the ceiling. See
1049    # CompetitionState.max_instances_per_team.
1050    max_instances_per_team: int = Field(default=0, ge=0, le=10_000)
1051    # Uploads one team may make in a rolling window, and the window; ``0``
1052    # is off. See CompetitionState.authoring_rate_limit_count.
1053    authoring_rate_limit_count: int = Field(default=5, ge=0, le=10_000)
1054    authoring_rate_limit_window_s: int = Field(default=7200, ge=0, le=31_536_000)
1055    # New uploads one team may start per authoring window; ``0`` is
1056    # unlimited. See CompetitionState.authoring_uploads_per_window.
1057    authoring_uploads_per_window: int = Field(default=0, ge=0, le=10_000)
1058    # What players are shown about how they are doing, and when (#2030).
1059    # See CompetitionState.report_disclosure.
1060    report_disclosure: ReportDisclosure = "default"
1061    # Whether an upload's author may see which teams played it (#2285). See
1062    # CompetitionState.author_team_detail.
1063    author_team_detail: AuthorTeamDetail = "off"
1064    # Lock the version of every uploaded challenge curated here while the
1065    # event is in play (#2041). See CompetitionState.lock_challenge_versions.
1066    lock_challenge_versions: bool = False
1067    # Publish the challenge count before the start. See
1068    # CompetitionState.pre_start_challenge_count_public.
1069    pre_start_challenge_count_public: bool = False
1070    # The word minted flags are wrapped in (#3267). See
1071    # CompetitionState.flag_prefix. Shape-checked by the route
1072    # (``422 flag_prefix_invalid``), so the refusal carries its own code.
1073    flag_prefix: str = Field(default="FLAG", max_length=64)
1074    # Uploads that may await review here at once, every team together;
1075    # ``0`` = no capacity. See CompetitionState.authoring_queue_capacity.
1076    authoring_queue_capacity: int = Field(default=0, ge=0)
1077    # The contributor agreement an upload here must accept: its version
1078    # and where to read it, both or neither (``""`` = none asked). See
1079    # CompetitionState.authoring_terms_version.
1080    authoring_terms_version: str = Field(default="", max_length=64)
1081    authoring_terms_url: WebUrl = Field(default="", max_length=500)
1082    # Where an entrant reaches the organisers; ``""`` is not set. Checked at
1083    # the route (``organiser_contact_invalid``). See
1084    # CompetitionState.organiser_contact_email.
1085    organiser_contact_email: str = Field(default="", max_length=254)
1086    # Who rules on an upload here; ``human`` for a caller who says nothing.
1087    # See CompetitionState.authoring_review.
1088    authoring_review: AuthoringReview = "human"
1089    # Whether the event runs only on nodes reserved for it (#1965 E); off
1090    # for a caller who says nothing. See CompetitionState.dedicated_pool.
1091    dedicated_pool: bool = False
1092    # Smoke runs per team per authoring window under automatic review
1093    # (#1965 C). See CompetitionState.authoring_smoke_quota.
1094    authoring_smoke_quota: int = Field(default=10, ge=1, le=1000)
1095    # Whether an upload here joins the public catalog when it is published
1096    # (#1989); off for a caller who says nothing. See
1097    # CompetitionState.authoring_public_on_publish.
1098    authoring_public_on_publish: bool = False
1099    # Whether an upload here is smoked automatically (#2721); on for a
1100    # caller who says nothing, which is today's behaviour. Off beside
1101    # automatic review is refused. See CompetitionState.authoring_auto_smoke.
1102    authoring_auto_smoke: bool = True
1103    # Whether authors here read the model review of their own upload
1104    # (#3050); off for a caller who says nothing — the 2026-10-30 rule. See
1105    # CompetitionState.authoring_authors_read_model_review.
1106    authoring_authors_read_model_review: bool = False
1107    # The pause after repeated rejections (#1965 C3); ``default`` for a
1108    # caller who says nothing — the review mode decides. See
1109    # CompetitionState.authoring_cooldown.
1110    authoring_cooldown: AuthoringCooldown = "default"
1111    authoring_cooldown_threshold: int = Field(default=3, ge=1, le=1000)
1112    authoring_cooldown_window_s: int = Field(default=7 * 24 * 3600, ge=60, le=365 * 24 * 3600)
1113    authoring_cooldown_pause_s: int = Field(default=7 * 24 * 3600, ge=60, le=365 * 24 * 3600)
1114    # Which rule ranks this competition's board, and its tuning. Empty
1115    # selects the platform default (``flag_count``), so an API caller
1116    # that says nothing gets exactly the historic behaviour. Set it here
1117    # rather than later where you can: once the competition goes live a
1118    # change is audited, announced on the board and needs a reason (see
1119    # ``scoring_live``).
1120    scoring_rule: ScoringRuleName | Literal[""] = ""
1121    scoring_params: dict[str, Any] = Field(default_factory=dict)
1122    #: What a multi-flag challenge is worth (#2731): ``challenge_total`` (the
1123    #: default for a new competition) shares one total among a challenge's
1124    #: parts by weight; ``legacy`` pays every flag the full value.
1125    scoring_rule_version: ScoringRuleVersion = DEFAULT_SCORING_RULE_VERSION
1126    # Silent exercise mode, orthogonal to ``scoring_rule`` on purpose.
1127    # ``CompetitionState.silent`` is the derivation.
1128    silent: bool = False
1129    # Playable slices inside the window. Empty (the default) means the
1130    # whole ``starts_at``..``ends_at`` window is playable, which is how
1131    # every competition has always behaved. Supplying sessions also
1132    # *sets* the window: ``CompetitionState`` derives ``starts_at`` /
1133    # ``ends_at`` from the envelope, so an organiser sending sessions
1134    # need not compute the outer dates and cannot contradict them.
1135    sessions: list[CompetitionSessionInfo] = Field(default_factory=list)
1136    # Seconds after the play window closes during which the engagement
1137    # report may still be saved. ``0`` (the default) means the report
1138    # closes with the event, which is exactly where the play gate closed
1139    # it before this field existed. See ``CompetitionState.report_window_s``.
1140    report_window_s: int = 0
1141    # Whether an entrant may withhold their identity here, and what
1142    # withholding looks like. Defaults ``forbidden`` — unlike
1143    # ``agents_allowed`` directly above, and for the opposite reason: an
1144    # API caller who says nothing must not get an event whose board
1145    # fills with pseudonyms nobody chose, and a real-name event (the
1146    # population this platform's PII dossier exists for) must be what
1147    # you get by default. See ``CompetitionState.anonymous_entry``.
1148    anonymous_entry: AnonymityPolicy = "forbidden"
1149    anonymous_entry_kind: AnonymityKind = "pseudonymous"
1150    # May this competition's instances reach the internet (#1969)?
1151    # Defaults ``allow`` for an API caller who says nothing, which is the
1152    # behaviour of every competition before the axis; the create *wizard*
1153    # makes the organiser pick one explicitly. ``challenge_internet`` is
1154    # the per-challenge override map, keyed by challenge id. See
1155    # CompetitionState.internet_policy.
1156    internet_policy: InternetPolicy = InternetPolicy.ALLOW
1157    challenge_internet: dict[str, InternetAccess] = Field(default_factory=dict)
1158    #: #2356: may a user's agent image reach the internet here? ``deny``
1159    #: unless the organiser says otherwise. See CompetitionState.agent_internet.
1160    agent_internet: InternetAccess = InternetAccess.DENY
1161    # How players reach a TCP challenge (#2082); ``tls_sni`` for a caller who
1162    # says nothing, the platform default. ``challenge_tcp_access`` is the
1163    # per-challenge override map. See CompetitionState.tcp_access.
1164    tcp_access: TcpAccess = TcpAccess.TLS_SNI
1165    challenge_tcp_access: dict[str, TcpAccess] = Field(default_factory=dict)
1166    # What a session's end / a full solve does to a running box (#2128 /
1167    # #2105). See CompetitionState.session_end_mode.
1168    session_end_mode: SessionEndMode = SessionEndMode.STOP_KEEP
1169    session_end_grace_s: int = Field(default=900, ge=0, le=86_400)
1170    solve_env_mode: SolveEnvMode = SolveEnvMode.GRACE
1171    solve_grace_s: int = Field(default=900, ge=0, le=86_400)
1172    # What a close does (#2183). See CompetitionState.after_close.
1173    after_close: AfterClose = "stop"
1174    practice_instance_cap: int = Field(default=1, ge=0, le=10_000)
1175    practice_ttl_s: int = Field(default=3600, ge=60, le=7 * 86_400)
1176    # Scoreboard freeze (#2133). See CompetitionState.freeze_minutes; ``0``
1177    # = never. ``unfrozen_at`` is not a field here -- publishing the result
1178    # is its own verb (``POST /admin/competitions/{id}/unfreeze``).
1179    freeze_minutes: int = Field(default=0, ge=0, le=10_080)
1180    freeze_auto_unfreeze: bool = False
1181    # What opening a hint costs (#2101). See CompetitionState.hint_cost_mode.
1182    hint_cost_mode: HintCostMode = "discount"
1183    # --- registration ---------------------------------------------------
1184    # The window sign-ups are open, distinct from the play window above.
1185    # ``CompetitionState.registration_starts_at`` is the derivation.
1186    registration_starts_at: datetime | None = None
1187    registration_ends_at: datetime | None = None
1188    # Per-competition roster cap. 0 defers to the platform-wide setting,
1189    # so two events on one deployment can carry different rules.
1190    max_team_size: int = Field(default=0, ge=0)
1191    # When true a newly registered team lands ``pending``. A marker only,
1192    # unless ``play_requires_approval`` is also on.
1193    registration_review: bool = False
1194    # When true (with review on) only an ``approved`` team may play or rank
1195    # (#2223). Off by default: review stays a marker.
1196    play_requires_approval: bool = False
1197    #: Whether a captain may no longer rename the team once the event has
1198    #: started (#2238). On by default; a board freeze locks renames regardless.
1199    team_rename_locked_after_start: bool = True
1200    # Institution-catalogue domains whose holders may register (#2184); ``[]``
1201    # = anyone. Exact registered domains, never a suffix. Each must exist in
1202    # the catalogue (``unknown_institution``).
1203    eligible_domains: list[str] = Field(default_factory=list)
1204    eligibility_mode: Literal["domains", "mainland_student"] = "domains"
1205    eligibility_carsi_roles: list[str] = Field(default_factory=list)
1206    team_entity_policy: Literal["none", "same_institution"] = "none"
1207    #: Students only (#2293): a CARSI proof must release the ``student`` role.
1208    eligibility_students_only: bool = False
1209    #: Fewest members a team needs (#2767); ``0`` = no floor.
1210    min_team_size: int = Field(default=0, ge=0)
1211    #: ``refuse`` an entrant who does not meet the conditions, or let the
1212    #: reviewer decide (#2767).
1213    on_ineligible: Literal["refuse", "review"] = "refuse"
1214    #: Whether an organiser's invitee is exempt from the conditions (#2767).
1215    eligibility_exempt_invited: bool = True
1216    #: Whether a verified school email satisfies the role block (#2932).
1217    eligibility_email_satisfies_role: bool = False
1218    #: Places (#2767 PR 6): ``0`` = no cap; what holds one; stop or waitlist.
1219    registration_capacity: int = Field(default=0, ge=0, le=100000)
1220    capacity_counts: CapacityCounts = "submitted"
1221    at_capacity: AtCapacity = "stop"
1222    # field name -> policy; anything absent is treated as hidden.
1223    # ``CompetitionState.registration_fields`` is the derivation.
1224    registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict)
1225    # Days after ``ends_at`` before the retention sweep blanks the three
1226    # sealed columns (real name, phone, postal address). ``0`` keeps them
1227    # indefinitely, which is what every competition predating this reads
1228    # as — the purge is irreversible, so turning it on is a decision
1229    # somebody makes about one event, never a default.
1230    pii_retention_days: int = Field(default=0, ge=0)
1231
1232    @field_validator("registration_fields")
1233    @classmethod
1234    def _known_registration_fields(
1235        cls, v: dict[str, RegistrationFieldPolicy]
1236    ) -> dict[str, RegistrationFieldPolicy]:
1237        return _check_registration_fields(v)
1238
1239    _norm_times = field_validator("starts_at", "ends_at", mode="before")(_blank_to_none)
1240
1241    #: The rounds this competition runs, configured in the same request.
1242    #:
1243    #: ⚠️ **Optional, and its absence is not "no match" — it is "not
1244    #: configured here".** A match is a side table keyed by competition
1245    #: id and ``PUT …/awd/match`` is an upsert, so a competition created
1246    #: without one can still be given one afterwards; that is how every
1247    #: match was made before this field existed. What it removes is the
1248    #: *dead end*: the ``awd_classic`` preset used to hand back a
1249    #: competition that looked configured and was inert, with the one
1250    #: thing that makes it a match reachable only from another page.
1251    #:
1252    #: ⚠️ **Not derived from ``scoring_rule``.** ``awd`` names how a
1253    #: board is *ranked* and says nothing about rounds —
1254    #: ``awd_scoreboard`` never reads it (quote ``grep -c scoring_rule
1255    #: ctfy/server/awd_scoreboard.py``) — so a competition may rank as
1256    #: AWD with no match, and a match may run under any rule. Coupling
1257    #: them here would make one axis silently decide the other.
1258    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 = ''
title_i18n: dict[str, 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
challenge_scope: Literal['set', 'catalog'] = 'set'
status: Literal['draft', 'published', 'archived'] = 'draft'
access: Literal['public', 'private_listed', 'private_hidden'] = 'public'
kind: ctfy.core.enums.CompetitionKind = <CompetitionKind.EVENT: 'event'>
bucket: str = ''
eval_standard: bool = False
corpus_sha: str = ''
source_name: str = ''
source_url: Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x74f512354e00>)] = ''
license: str = ''
maintainer: str = ''
version_date: str = ''
agents_allowed: bool = True
authoring_enabled: bool = False
agent_pulse: bool = False
entrants_are_agents: bool = False
authoring_windows: list[AuthoringWindowInfo] = PydanticUndefined
authoring_pending_per_team: int = 0
max_rebuilds_per_challenge: int = 0
max_instances_per_team: int = 0
authoring_rate_limit_count: int = 5
authoring_rate_limit_window_s: int = 7200
authoring_uploads_per_window: int = 0
report_disclosure: Literal['default', 'after_lock', 'own', 'own_and_board'] = 'default'
author_team_detail: Literal['off', 'after_end', 'always'] = 'off'
lock_challenge_versions: bool = False
pre_start_challenge_count_public: bool = False
flag_prefix: str = 'FLAG'
authoring_queue_capacity: int = 0
authoring_terms_version: str = ''
authoring_terms_url: Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x74f512354e00>)] = ''
organiser_contact_email: str = ''
authoring_review: Literal['human', 'automatic'] = 'human'
dedicated_pool: bool = False
authoring_smoke_quota: int = 10
authoring_public_on_publish: bool = False
authoring_auto_smoke: bool = True
authoring_authors_read_model_review: bool = False
authoring_cooldown: Literal['default', 'platform', 'competition', 'off'] = 'default'
authoring_cooldown_threshold: int = 3
authoring_cooldown_window_s: int = 604800
authoring_cooldown_pause_s: int = 604800
scoring_rule: Union[Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring', 'authoring'], Literal['']] = ''
scoring_params: dict[str, typing.Any] = PydanticUndefined
scoring_rule_version: Literal['legacy', 'challenge_total'] = 'challenge_total'
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'
internet_policy: ctfy.core.enums.InternetPolicy = <InternetPolicy.ALLOW: 'allow'>
challenge_internet: dict[str, ctfy.core.enums.InternetAccess] = PydanticUndefined
agent_internet: ctfy.core.enums.InternetAccess = <InternetAccess.DENY: 'deny'>
tcp_access: ctfy.core.enums.TcpAccess = <TcpAccess.TLS_SNI: 'tls_sni'>
challenge_tcp_access: dict[str, ctfy.core.enums.TcpAccess] = PydanticUndefined
session_end_mode: ctfy.core.enums.SessionEndMode = <SessionEndMode.STOP_KEEP: 'stop_keep'>
session_end_grace_s: int = 900
solve_env_mode: ctfy.core.enums.SolveEnvMode = <SolveEnvMode.GRACE: 'grace'>
solve_grace_s: int = 900
after_close: Literal['stop', 'practice'] = 'stop'
practice_instance_cap: int = 1
practice_ttl_s: int = 3600
freeze_minutes: int = 0
freeze_auto_unfreeze: bool = False
hint_cost_mode: Literal['discount', 'fixed', 'record'] = 'discount'
registration_starts_at: datetime.datetime | None = None
registration_ends_at: datetime.datetime | None = None
max_team_size: int = 0
registration_review: bool = False
play_requires_approval: bool = False
team_rename_locked_after_start: bool = True
eligible_domains: list[str] = PydanticUndefined
eligibility_mode: Literal['domains', 'mainland_student'] = 'domains'
eligibility_carsi_roles: list[str] = PydanticUndefined
team_entity_policy: Literal['none', 'same_institution'] = 'none'
eligibility_students_only: bool = False
min_team_size: int = 0
on_ineligible: Literal['refuse', 'review'] = 'refuse'
eligibility_exempt_invited: bool = True
eligibility_email_satisfies_role: bool = False
registration_capacity: int = 0
capacity_counts: Literal['submitted', 'approved'] = 'submitted'
at_capacity: Literal['stop', 'waitlist'] = 'stop'
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):
2897class CompetitionDetail(CompetitionInfo):
2898    """Full detail payload — includes a *preview* of the resolved
2899    challenge summaries and the caller's registration state when
2900    authenticated.
2901
2902    ⚠️ ``challenges`` is bounded by
2903    :data:`~ctfy.server.routes.competitions.COMPETITION_CHALLENGE_PREVIEW`
2904    and is **not** the competition's challenge set. It used to be, and a
2905    bucket competition put hundreds of full ``ChallengeInfo`` rows —
2906    each carrying questions, attachments and a defence block — through
2907    a payload whose only reader renders six cards.
2908
2909    Anything that means *all of them* must page
2910    ``GET /competitions/{id}/challenges`` (SDK:
2911    ``client.competition(id).challenges(offset, limit)``), and anything
2912    that means *how many* must read ``challenge_count``. Both existed as
2913    ``len(challenges)`` before the bound and would have kept working
2914    while reporting the preview size — which is why the two aggregates
2915    below are served rather than left to the caller to derive.
2916    """
2917
2918    #: The organiser's long-form rules page, markdown.
2919    #:
2920    #: ⚠️ **On the detail payload and deliberately not on
2921    #: ``CompetitionInfo``.** ``GET /competitions`` pages 50 rows of that
2922    #: model at a time and this field is capped at 20 000 characters, so
2923    #: publishing it there is ``AuthoredChallengeSummary``'s recorded
2924    #: defect — a column projection carrying the blob. The rules page
2925    #: reads it off ``CompetitionContext``, which ``CompetitionLayout``
2926    #: already fetched, so it costs no request of its own.
2927    handbook: LocalizedText = Field(default_factory=dict)
2928    #: When the handbook last really changed; ``None`` while nobody has
2929    #: written one. Its own column rather than ``updated_at``, which
2930    #: moves whenever any field does — see ``CompetitionState``.
2931    handbook_updated_at: datetime | None = None
2932    #: The organiser's files — rules, handbook, notice (#1808) — in
2933    #: display order and **every locale's**; the page filters to the
2934    #: reader's language. On the detail payload for the handbook's own
2935    #: reason: the rules page reads the context and costs no request.
2936    attachments: list[CompetitionAttachmentInfo] = Field(default_factory=list)
2937
2938    challenges: list[ChallengeInfo] = Field(default_factory=list)
2939    # ``challenge_count`` (on ``CompetitionInfo``) is the true total, and
2940    # ``difficulty_counts`` the true breakdown, over the whole resolved set
2941    # rather than the preview. Derived here because a client cannot recover them from a
2942    # bounded list, and two clients deriving them differently is two
2943    # clients disagreeing about how big the competition is.
2944    difficulty_counts: dict[str, int] = Field(default_factory=dict)
2945    registered_at: datetime | None = None
2946    #: #2885: the event's own authoring settings (``0`` = the platform's)
2947    #: and the platform's values in force now, which the form shows beside
2948    #: each field. ⚠️ **Organisers only** — ``None`` for every other reader,
2949    #: and never on ``CompetitionInfo``'s listing.
2950    authoring_overrides: AuthoringSettings | None = None
2951    authoring_platform: AuthoringSettings | None = None
2952    #: #3053: the AI pre-check's models, its white-box model, and the gateway
2953    #: models an organiser may choose from. ⚠️ **Organisers only**, like the
2954    #: overrides above: which providers see an event's challenges is the
2955    #: organisers' business, not a player's.
2956    authoring_probe_models: list[str] | None = None
2957    authoring_probe_whitebox_model: str | None = None
2958    #: #3189: blind runs per model on each version (1..3). Organisers only.
2959    authoring_probe_blind_runs: int | None = None
2960    authoring_probe_choices: list[ProbeModelChoice] | 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
difficulty_counts: dict[str, int] = PydanticUndefined
registered_at: datetime.datetime | None = None
authoring_overrides: ctfy.core.authoring_overrides.AuthoringSettings | None = None
authoring_platform: ctfy.core.authoring_overrides.AuthoringSettings | None = None
authoring_probe_models: list[str] | None = None
authoring_probe_whitebox_model: str | None = None
authoring_probe_blind_runs: int | None = None
authoring_probe_choices: list[ProbeModelChoice] | None = None
class CompetitionDifferenceInfo(ctfy.core.models.CtfyModel):
647class CompetitionDifferenceInfo(CtfyModel):
648    """One place a test competition differs from its official one (#2041).
649
650    ``field`` is a competition field, ``<table>`` for a copied side table
651    (``competition_attachments``, ``competition_topics``, …),
652    ``awd_matches.<knob>`` for one knob of the match, or ``sessions.shape``.
653    ``kind`` is ``intentional`` — on the allow-list, with ``reason`` saying
654    why — or ``warning``: a rule the two do not share. ``test`` and
655    ``official`` are each side's value as canonical JSON.
656    """
657
658    field: str
659    kind: DifferenceKind
660    test: str
661    official: str
662    reason: str = ""

One place a test competition differs from its official one (#2041).

field is a competition field, <table> for a copied side table (competition_attachments, competition_topics, …), awd_matches.<knob> for one knob of the match, or sessions.shape. kind is intentional — on the allow-list, with reason saying why — or warning: a rule the two do not share. test and official are each side's value as canonical JSON.

field: str = PydanticUndefined
kind: Literal['intentional', 'warning'] = PydanticUndefined
test: str = PydanticUndefined
official: str = PydanticUndefined
reason: str = ''
class CompetitionDriftInfo(ctfy.core.models.CtfyModel):
665class CompetitionDriftInfo(CtfyModel):
666    """``GET /admin/competitions/{id}/drift`` — one test against its official event.
667
668    ``warnings`` is the number an operator acts on — the headline
669    "N differences from the official competition" — and ``intentional``
670    the allow-listed ones beside it. ``official_missing`` is a test whose
671    official competition no longer exists: there is nothing to compare,
672    and that is said rather than drawn as zero differences.
673    """
674
675    test_id: str
676    test_title: str
677    official_id: str
678    official_title: str = ""
679    official_missing: bool = False
680    warnings: int = 0
681    intentional: int = 0
682    differences: list[CompetitionDifferenceInfo] = Field(default_factory=list)

GET /admin/competitions/{id}/drift — one test against its official event.

warnings is the number an operator acts on — the headline "N differences from the official competition" — and intentional the allow-listed ones beside it. official_missing is a test whose official competition no longer exists: there is nothing to compare, and that is said rather than drawn as zero differences.

test_id: str = PydanticUndefined
test_title: str = PydanticUndefined
official_id: str = PydanticUndefined
official_title: str = ''
official_missing: bool = False
warnings: int = 0
intentional: int = 0
differences: list[CompetitionDifferenceInfo] = PydanticUndefined
class CompetitionEditionRef(ctfy.core.models.CtfyModel):
70class CompetitionEditionRef(CtfyModel):
71    """The competition a ``previous_edition_id`` names, as its organiser sees it."""
72
73    id: str
74    title: str = ""
75    title_i18n: LocalizedText = Field(default_factory=dict)

The competition a previous_edition_id names, as its organiser sees it.

id: str = PydanticUndefined
title: str = ''
title_i18n: dict[str, str] = PydanticUndefined
class CompetitionEligibility(ctfy.core.models.CtfyModel):
 76class CompetitionEligibility(CtfyModel):
 77    """Whether the caller may register, and what to do about it.
 78
 79    ``restricted=False`` is the ordinary competition; every other field is
 80    then empty and ``state`` is ``open``.
 81    """
 82
 83    competition_id: str = ""
 84    restricted: bool = False
 85    eligibility_mode: Literal["domains", "mainland_student"] = "domains"
 86    institutions: list[EligibleInstitution] = Field(default_factory=list)
 87    state: EligibilityState = "open"
 88    #: ``True`` when an organiser's invitation (or being an organiser) lets
 89    #: the caller in whatever ``state`` says. ``state`` stays the truth about
 90    #: their addresses, so the page can still offer to verify one.
 91    exempt: bool = False
 92    #: Why (#2293): ``organiser`` (a platform or competition admin) or
 93    #: ``invited``; ``""`` when not exempt. Set by the same judgement as
 94    #: ``exempt``, so the card never explains an exemption the door did not give.
 95    exempt_reason: ExemptReason = ""
 96    #: Which proof qualifies (``state == "eligible"``): a proved ``email`` or
 97    #: a bound ``carsi`` identity (#2282). ``""`` for every other state.
 98    #: The school rule admits students only (#2293): a bound CARSI account must
 99    #: release the ``student`` role; the email rule is unchanged.
100    students_only: bool = False
101    #: A CARSI binding is at an accepted school but released this role, not
102    #: ``student`` (#2293); ``"unknown"`` when it released none; ``""`` otherwise.
103    #: Tells the page *why* it was refused instead of asking for an address.
104    carsi_refused_role: str = ""
105    via: EligibilityVia = ""
106    #: The institution domain that qualifies, whichever proof it came by.
107    via_domain: str = ""
108    #: The proved address that qualifies (``via == "email"``).
109    via_email: str = ""
110    #: The held, unproved address at an allowed domain (``needs_verification``).
111    pending_email: str = ""
112    review_domain: str = ""
113    #: The single answer a client gates its button on, composed here so no
114    #: client restates ``restricted`` / ``exempt`` / ``state`` itself.
115    may_register: bool = True
116    #: The player's checklist (#2767); ``None`` when the competition enables
117    #: no condition block at all, whether or not it is ``restricted``.
118    checklist: PlayerChecklist | None = None
119    #: The places and the caller's standing (#2767 PR 6); ``None`` = no cap.
120    capacity: PlayerCapacity | None = None

Whether the caller may register, and what to do about it.

restricted=False is the ordinary competition; every other field is then empty and state is open.

competition_id: str = ''
restricted: bool = False
eligibility_mode: Literal['domains', 'mainland_student'] = 'domains'
institutions: list[EligibleInstitution] = PydanticUndefined
state: Literal['open', 'eligible', 'needs_email', 'needs_verification', 'needs_review'] = 'open'
exempt: bool = False
exempt_reason: Literal['', 'organiser', 'invited'] = ''
students_only: bool = False
carsi_refused_role: str = ''
via: Literal['', 'email', 'carsi'] = ''
via_domain: str = ''
via_email: str = ''
pending_email: str = ''
review_domain: str = ''
may_register: bool = True
checklist: ctfy.server.models.eligibility.PlayerChecklist | None = None
capacity: PlayerCapacity | None = None
class CompetitionExclusion(ctfy.core.models.CtfyModel):
101class CompetitionExclusion(CtfyModel):
102    """What a competition's page says about its exclusion groups (#2301, #2882).
103
104    ``exclusive`` is ``False`` for the ordinary competition, and every other
105    field is then empty. ``peers`` are the competitions that share **any**
106    group with this one (a competition may be in several), deduplicated and
107    never this one -- and only those the *reader* may open; the rest are
108    counted in ``hidden`` and never named, since a hidden competition's title
109    is not for a reader to learn from a notice. Two competitions that share no
110    group never appear in each other's notice.
111
112    ``switch_after_start`` is ``True`` only when *every* group this
113    competition is in allows switching: the stricter answer, as the verdict
114    decides (``core.exclusion.strictest_exclusion``).
115
116    ``blocked`` is the advisory answer for *this caller*: ``holding`` /
117    ``switch_closed`` when registering here would be refused, ``""`` when it
118    would not (or the caller is anonymous). ``blocked_by`` is the competition
119    behind it, ``None`` when the caller may not be told which.
120    """
121
122    exclusive: bool = False
123    switch_after_start: bool = False
124    peers: list[ExclusionPeer] = Field(default_factory=list)
125    hidden: int = 0
126    blocked: ExclusionReason | None = None
127    blocked_by: ExclusionPeer | None = None

What a competition's page says about its exclusion groups (#2301, #2882).

exclusive is False for the ordinary competition, and every other field is then empty. peers are the competitions that share any group with this one (a competition may be in several), deduplicated and never this one -- and only those the reader may open; the rest are counted in hidden and never named, since a hidden competition's title is not for a reader to learn from a notice. Two competitions that share no group never appear in each other's notice.

switch_after_start is True only when every group this competition is in allows switching: the stricter answer, as the verdict decides (core.exclusion.strictest_exclusion).

blocked is the advisory answer for this caller: holding / switch_closed when registering here would be refused, "" when it would not (or the caller is anonymous). blocked_by is the competition behind it, None when the caller may not be told which.

exclusive: bool = False
switch_after_start: bool = False
peers: list[ExclusionPeer] = PydanticUndefined
hidden: int = 0
blocked: Optional[Literal['holding', 'switch_closed']] = None
blocked_by: ExclusionPeer | None = None
class CompetitionFormatPreset(ctfy.core.models.CtfyModel):
432class CompetitionFormatPreset(CtfyModel):
433    """One named starting point for the create-competition wizard.
434
435    ⚠️ **Served rather than mirrored in the frontend, and the reason is
436    ``scoring_params``.** A preset's params have to survive
437    ``build_rule``, which is strict — a client-side copy naming a curve
438    this server does not accept would pass every frontend check and 422
439    the organiser on the wizard's last step, with the cause six screens
440    back. Keeping the catalogue on the side that validates it makes that
441    class of drift impossible rather than tested-for.
442
443    Read-only and unstored: see ``ctfy.core.competition_formats`` for why
444    a competition never records which preset made it.
445    """
446
447    id: str
448    #: Empty selects the platform default, exactly as it does on
449    #: ``CompetitionCreate`` — see the core module's note on why a
450    #: preset does not spell today's default out.
451    scoring_rule: ScoringRuleName | Literal[""] = ""
452    scoring_params: dict[str, Any] = Field(default_factory=dict)
453    silent: bool = False
454    #: This format is configured in the competition's own match settings
455    #: and does not read ``scoring_rule`` at all.
456    needs_awd_match: bool = False
457    #: The format's deliverable has no deadline until one is set, and
458    #: ``0`` closes the report with the event rather than meaning unset.
459    needs_report_window: bool = False
460    #: Whether this format needs the authoring door open. A board that
461    #: scores what entrants wrote is inert without it — see the core
462    #: dataclass for why this axis is set rather than surfaced.
463    authoring_enabled: bool = False
464    #: Who attacks each round: ``none`` is a melee between the entrants,
465    #: anything else is defence-only. A format axis, not a knob — see
466    #: the core dataclass for what picking the wrong one costs.
467    awd_attacker: AwdAttacker = AwdAttacker.NONE
468    #: The **match's** scoring knobs, not the competition's. Empty means
469    #: the packaged defaults; a defence-only format has to say something
470    #: here or its optimal opening is for a defender to stop their own
471    #: service. ``DEFENCE_ONLY_KNOBS`` carries the arithmetic.
472    awd_scoring_params: dict[str, float] = Field(default_factory=dict)
473    #: Is the field of entrants dispatched agents rather than humans — an
474    #: agent exhibition? The wizard seeds ``entrants_are_agents`` on the
475    #: created competition from this. See the core dataclass.
476    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', '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 CompetitionGroupRef(ctfy.core.models.CtfyModel):
90class CompetitionGroupRef(CtfyModel):
91    """Response: the groups a competition is in, by id; ``[]`` is none.
92
93    ⚠️ A model of its own rather than ``CompetitionGroupSet`` reused: one model
94    on both sides of a route splits into ``-Input`` / ``-Output`` in the OpenAPI
95    document, and the generated type loses its plain name.
96    """
97
98    group_ids: list[str] = Field(default_factory=list)

Response: the groups a competition is in, by id; [] is none.

⚠️ A model of its own rather than CompetitionGroupSet reused: one model on both sides of a route splits into -Input / -Output in the OpenAPI document, and the generated type loses its plain name.

group_ids: list[str] = PydanticUndefined
class CompetitionGroupSet(ctfy.core.models.CtfyModel):
80class CompetitionGroupSet(CtfyModel):
81    """Request: the groups one competition is in, exactly; ``[]`` takes it out of all.
82
83    A competition may be in several groups (#2882). Only this competition's
84    memberships move; every other member of each group stays.
85    """
86
87    group_ids: list[str] = Field(default_factory=list)

Request: the groups one competition is in, exactly; [] takes it out of all.

A competition may be in several groups (#2882). Only this competition's memberships move; every other member of each group stays.

group_ids: list[str] = PydanticUndefined
class CompetitionInfo(ctfy.core.models.CtfyModel):
1836class CompetitionInfo(CtfyModel):
1837    """Summary row used in the list view.
1838
1839    Per-viewer fields (``is_registered`` / ``my_*``) are populated only
1840    when the request carries an authenticated user; for anonymous
1841    requests they fall back to sentinels (empty strings, ``-1``) so
1842    the response shape stays stable. Front-end treats ``my_rank < 0``
1843    as "no value" rather than "rank zero".
1844    """
1845
1846    id: str
1847    #: The title resolved to the base locale (then any locale with text) —
1848    #: the one string the CLI, the SDK and MCP print. A browser renders
1849    #: :attr:`title_i18n` instead, so the name follows the language toggle.
1850    title: str
1851    #: ``{locale: text}`` (#2230) — resolved in the browser by
1852    #: ``competitionTitle`` in ``lib/localizedText.ts``.
1853    title_i18n: LocalizedText = Field(default_factory=dict)
1854    #: ``{locale: text}`` — resolved in the browser by
1855    #: ``lib/localizedText.ts`` so it follows the language toggle
1856    #: without a refetch, and server-side by
1857    #: ``ctfy.core.localized.resolve_localized`` where there is no
1858    #: browser (email, the CLI).
1859    description: LocalizedText = Field(default_factory=dict)
1860    starts_at: datetime | None = None
1861    ends_at: datetime | None = None
1862    created_at: datetime | None = None
1863    updated_at: datetime | None = None
1864    created_by: str
1865    created_by_name: str
1866    #: The competition's challenge set — ``[]`` while
1867    #: ``challenges_withheld``.
1868    challenge_ids: list[str] = Field(default_factory=list)
1869    #: Whether this reader is held off the challenge set because the event
1870    #: has not started (``server.challenge_reveal``); organisers never are.
1871    #: Composed server-side so a client renders "revealed at the start"
1872    #: rather than inferring it from an empty list, which is also what a
1873    #: competition that curated nothing looks like.
1874    challenges_withheld: bool = False
1875    #: How many challenges the event has, or ``None`` when this reader is
1876    #: not told: before the start the number is withheld with the set
1877    #: unless the organiser published it (``pre_start_challenge_count_public``).
1878    #: ⚠️ ``None`` means *not disclosed*, never "none" — a client must not
1879    #: render it as ``0``. On the detail payload it counts the whole
1880    #: resolved set, not the preview (``CompetitionDetail.challenges``).
1881    challenge_count: int | None = None
1882    #: The catalog directory a ``bucket`` competition reads live, ``""``
1883    #: for one that curates its own set. Published so the organiser's
1884    #: challenge page can say *why* it offers no order/curation rather
1885    #: than offering controls the server then refuses (#2037 B3).
1886    bucket: str = ""
1887    #: What may be played there (#2553, ``CompetitionState.challenge_scope``):
1888    #: ``set`` — the challenge set and nothing else; ``catalog`` — any catalog
1889    #: challenge may start. ⚠️ Read this, never the emptiness of
1890    #: ``challenge_ids`` (which is also ``[]`` while withheld).
1891    challenge_scope: ChallengeScope = "set"
1892    # Visibility gate. Only ``published`` rows reach non-admins; admins
1893    # and per-competition admins also receive ``draft`` / ``archived``.
1894    status: CompetitionStatus = "published"
1895    # Participation access. ``public`` is open; ``private_listed`` stays
1896    # discoverable but invite-only to register; ``private_hidden`` is also
1897    # hidden from non-invited users. Default ``public`` for backward
1898    # compat (legacy rows / anonymous responses).
1899    access: CompetitionAccess = "public"
1900    #: The series this contest is an occurrence of, empty for a one-off.
1901    #: Carried so a client can reach the **ladder** — before this the id
1902    #: was on no wire model, so the whole rating surface was unreachable
1903    #: from the browser however public its route was. Not a disclosure:
1904    #: it names a series whose ladder the reader may already read, and
1905    #: nothing about the series' pool or cadence travels with it.
1906    series_id: str = ""
1907    #: That series' name, so a card or a detail page can *name* the series
1908    #: it links to (`/series/:id`) rather than print a uuid. ``""`` for a
1909    #: one-off, and for a series deleted since it spawned this sitting.
1910    series_name: str = ""
1911    #: Hosted event or benchmark suite (#2097). Public: the players' lists, the
1912    #: detail page and the admin chip all read it. ⚠️ Defaults ``event``, so a
1913    #: server that predates the field leaves every row on the contest list.
1914    kind: CompetitionKind = CompetitionKind.EVENT
1915    # Eval standard-set marker + pinned corpus snapshot (admin-only meaning;
1916    # see CompetitionState). Surfaced so the admin form can show/edit it.
1917    eval_standard: bool = False
1918    corpus_sha: str = ""
1919    #: Optional suite provenance (#2218); empty means the organiser has not stated it.
1920    source_name: str = ""
1921    source_url: str = ""
1922    license: str = ""
1923    maintainer: str = ""
1924    version_date: str = ""
1925    #: What this board ranks by, in precedence order — the first entry
1926    #: is the headline number and the rest are the tie-breaks.
1927    #:
1928    #: ⚠️ **A property of the rule, so it belongs on the competition
1929    #: rather than on the board page.** The rule is configuration, not
1930    #: progress — a live change is a recorded, announced event
1931    #: (``scoring_live``), never a drift — so putting it on the paginated
1932    #: board would repeat it per page and make page two able to disagree
1933    #: with page one.
1934    #:
1935    #: ⚠️ Empty from a server that predates it, which reads as "no
1936    #: explanation offered" rather than "nothing was sorted" — the
1937    #: client renders nothing instead of claiming the board is unranked.
1938    ordering: list[BoardCriterion] = Field(default_factory=list)
1939    #: Whether a user may enter their own agent here. Public, because the
1940    #: competition page renders a different thing for each answer and a
1941    #: client cannot derive it — ``eval_standard`` is a *different*
1942    #: question and reading it as this one is the bug this replaces.
1943    #:
1944    #: ⚠️ Defaults ``True`` here too, so an older server that does not
1945    #: send the field leaves the page offering the door rather than
1946    #: taking it away. Gates fail open.
1947    agents_allowed: bool = True
1948    #: Whether this competition asks entrants to write challenges.
1949    #:
1950    #: ⚠️ Defaults **False** here, the opposite of ``agents_allowed``
1951    #: directly above, and for the opposite reason. That one is a
1952    #: *gate*, so an older server that omits it must leave the door
1953    #: open. This one is an *offer*: a client reading a default of
1954    #: ``True`` would show every player of every event an authoring
1955    #: page that refuses them. Silence must claim nothing, and here
1956    #: claiming nothing is ``False``.
1957    authoring_enabled: bool = False
1958    #: Does this event's wall draw its agents' model traffic? ⚠️
1959    #: Defaults ``False`` on both sides, and the client reads it as the
1960    #: scene's one axis — so an older server saying nothing leaves the
1961    #: wall exactly as it was rather than growing a panel nobody armed.
1962    agent_pulse: bool = False
1963    #: Is the field of entrants dispatched agents, not humans — an agent
1964    #: exhibition? The browser reads this to replace the "register" CTA
1965    #: with a spectate affordance and to badge the card/detail page.
1966    #:
1967    #: ⚠️ Defaults **False** here, ``authoring_enabled``'s direction. A
1968    #: client reading an older server's silence as ``True`` would take
1969    #: the sign-up button away from a real, human-registerable event —
1970    #: the over-blocking failure. The server still refuses a human on an
1971    #: exhibition regardless (``register_for_competition``); this field
1972    #: only decides what the page *offers*. See
1973    #: CompetitionState.entrants_are_agents.
1974    entrants_are_agents: bool = False
1975    #: The periods uploads are taken in; empty means "for as long as the
1976    #: event runs". Dates rather than a phase, the registration window's
1977    #: reason: a phase is a function of the clock, so the browser rules
1978    #: on these (``frontend/src/lib/authoringWindow.ts``) and the route
1979    #: on ``core.competitions.authoring_phase``.
1980    authoring_windows: list[AuthoringWindowInfo] = Field(default_factory=list)
1981    #: This competition's own per-team pending cap (``0`` = the
1982    #: platform's) and its whole review queue's capacity (``0`` = none).
1983    #: Carried as stored, the way ``max_team_size`` is, so the edit form
1984    #: prefills what the organiser set rather than a resolved number it
1985    #: would then save back as an override. Not sensitive: both are
1986    #: rules an author meets at the door anyway.
1987    authoring_pending_per_team: int = 0
1988    authoring_queue_capacity: int = 0
1989    #: This competition's own rebuild quota per team per challenge
1990    #: (``0`` = the platform's). Carried as stored, the per-team pending
1991    #: cap's reason: the edit form prefills what the organiser set. Not
1992    #: sensitive — a player meets the rule at the reset button anyway.
1993    max_rebuilds_per_challenge: int = 0
1994    #: This competition's own cap on instances one team may hold at once
1995    #: (``0`` = the platform's), as stored — the edit form prefills what the
1996    #: organiser set. ``max_instances_per_team_effective`` is the number a
1997    #: start is actually held to, the platform's ceiling applied.
1998    max_instances_per_team: int = 0
1999    max_instances_per_team_effective: int = 0
2000    #: The upload rate limit as stored (``0`` = off), so the edit form
2001    #: prefills what the organiser set. See
2002    #: CompetitionState.authoring_rate_limit_count.
2003    authoring_rate_limit_count: int = 5
2004    authoring_rate_limit_window_s: int = 7200
2005    #: New uploads one team may start per authoring window (``0`` = unlimited),
2006    #: as stored. See CompetitionState.authoring_uploads_per_window.
2007    authoring_uploads_per_window: int = 0
2008    #: What players are shown about how they are doing, and when (#2030) —
2009    #: the stored value, ``default`` included, so the edit form prefills
2010    #: what the organiser chose rather than what it resolves to.
2011    report_disclosure: ReportDisclosure = "default"
2012    #: Whether an upload's author may see which teams played it (#2285) --
2013    #: the stored setting, so the edit form prefills it.
2014    author_team_detail: AuthorTeamDetail = "off"
2015    #: Whether this event locks its uploaded challenges' versions while in
2016    #: play (#2041) — the stored setting, so the edit form prefills it.
2017    lock_challenge_versions: bool = False
2018    #: Whether the count is published before the start — the stored
2019    #: setting, so the edit form prefills it. See
2020    #: CompetitionState.pre_start_challenge_count_public.
2021    pre_start_challenge_count_public: bool = False
2022    #: The word minted flags are wrapped in (#3267). Public: a player is
2023    #: told the flag format, and it is no secret — every reader of a flag
2024    #: accepts any prefix. See CompetitionState.flag_prefix.
2025    flag_prefix: str = "FLAG"
2026    #: Server-derived: whether the prefix can no longer change
2027    #: (``core.competitions.flag_prefix_locked``). The form disables the
2028    #: input off this rather than re-deriving the rule.
2029    flag_prefix_locked: bool = False
2030    #: Whether that lock holds right now — ``core.challenge_version_lock.
2031    #: versions_locked``, a reading of the clock as well as the row. The
2032    #: edit form asks for a reason before changing the set when it does.
2033    challenge_versions_locked: bool = False
2034    #: The official competition this one is the test of (#2041), ``""``
2035    #: for an official one. ``core.competition_clone`` builds a test; the
2036    #: drift view (``GET /admin/competitions/{id}/drift``) compares the two.
2037    test_of: str = ""
2038    #: Whether the standings are open to players now —
2039    #: ``core.competitions.standings_open``. A reading of the clock as well
2040    #: as the row: a silent event's open at its report lock.
2041    standings_open: bool = True
2042    #: The contributor agreement an upload must accept — the version in
2043    #: force and where to read it; ``""`` when the event asks for none.
2044    #: Public, because the author reads both before uploading.
2045    authoring_terms_version: str = ""
2046    authoring_terms_url: str = ""
2047    #: Where an entrant reaches the organisers (#2283); ``""`` when none was
2048    #: set, and the page then shows nothing. Public on purpose: it is
2049    #: printed on the competition page for anyone who may see the event.
2050    organiser_contact_email: str = ""
2051
2052    @computed_field
2053    @property
2054    def registration_takes_agreement(self) -> bool:
2055        """Whether registering a team here includes its captain accepting
2056        the contributor agreement for the whole team (#1965 B) — composed
2057        server-side (`registration_takes_the_agreement`) so the wizard never
2058        keeps its own copy of the rule."""
2059        return registration_takes_the_agreement(
2060            authoring_enabled=self.authoring_enabled,
2061            terms_version=self.authoring_terms_version,
2062        )
2063
2064    #: Whether this competition's instances may reach the internet
2065    #: (#1969): ``allow``, ``deny``, or ``per_challenge`` — each challenge
2066    #: as its author declared. Public, because a player and their agent
2067    #: plan around it; each challenge's own verdict is on
2068    #: ``ChallengeInfo.internet``. ``allow`` from an older server, which is
2069    #: what it did.
2070    internet_policy: InternetPolicy = InternetPolicy.ALLOW
2071    #: #2356: whether a user's agent image may reach the public internet in
2072    #: this competition — what the entry wizard's confirm step and the run
2073    #: page show as "本测评是否联网". Public, because an entrant plans their
2074    #: agent around it. The platform API, the LLM gateway and the run's own
2075    #: target are reachable either way. ``deny`` from an older server reads
2076    #: as the stricter claim it is.
2077    agent_internet: InternetAccess = InternetAccess.DENY
2078    #: How players reach this event's TCP challenges by default (#2082).
2079    #: Public, because a player plans around whether they need a VPN client;
2080    #: each instance's own way in is on its TCP endpoints (``access``).
2081    #: ``tls_sni`` from an older server, which is what it did.
2082    tcp_access: TcpAccess = TcpAccess.TLS_SNI
2083    #: What a session's end does to a running box, and what a full solve
2084    #: does to the box it was solved on (#2128 / #2105). Public: a player
2085    #: plans around whether their environment survives the night.
2086    session_end_mode: SessionEndMode = SessionEndMode.DELETE
2087    session_end_grace_s: int = 900
2088    solve_env_mode: SolveEnvMode = SolveEnvMode.DESTROY
2089    solve_grace_s: int = 900
2090    #: What happens once a challenge closes or the event ends (#2183):
2091    #: ``stop`` refuses play, ``practice`` keeps it playable and unscored.
2092    #: Public: a player plans around whether they can keep practising.
2093    #: ``stop`` from an older server, which is what it did.
2094    after_close: AfterClose = "stop"
2095    #: Practice environments one team may run at once (``0`` = the event's
2096    #: own instance cap) and how long one lives, renewable (seconds).
2097    practice_instance_cap: int = 1
2098    practice_ttl_s: int = 3600
2099    #: Scoreboard freeze (#2133). ``freeze_minutes`` ``0`` = this event never
2100    #: freezes. ``freeze_at`` is the derived instant the players' board stops
2101    #: (``None`` when it never does); ``unfrozen_at`` is when the organiser
2102    #: published the final result. ``board_frozen`` is **per reader**: true
2103    #: only while *this* viewer is looking at a held board -- an organiser
2104    #: reads live, so it is false for them even in the freeze window.
2105    freeze_minutes: int = 0
2106    freeze_auto_unfreeze: bool = False
2107    #: What opening a hint costs here (#2101), and the mode the board
2108    #: actually runs (``record`` where it prices nothing).
2109    hint_cost_mode: HintCostMode = "discount"
2110    effective_hint_cost_mode: HintCostMode = "discount"
2111    #: Whether some of this event's challenges are not yet released **to
2112    #: this reader** (#2093) — "还有题目尚未放出". Never which, how many or
2113    #: when: an unreleased challenge does not exist for a player.
2114    unreleased_pending: bool = False
2115    freeze_at: datetime | None = None
2116    unfrozen_at: datetime | None = None
2117    board_frozen: bool = False
2118    #: Whether an organiser has published the recap at
2119    #: ``/competitions/{id}/recap`` (#3023 2e); its figures are read there.
2120    recap_published: bool = False
2121    #: Who rules on an upload here — ``human`` or ``automatic``. Public,
2122    #: because an author meets its rule at the door: under ``automatic``
2123    #: every question's answer must be minted per instance.
2124    authoring_review: AuthoringReview = "human"
2125    #: Whether this event runs only on nodes reserved for it (#1965 E).
2126    #: Which nodes, and how many are live, is the admin route
2127    #: ``/admin/competitions/{id}/pool`` — node ids are infrastructure.
2128    #: ``false`` from an older server, which is what it did.
2129    dedicated_pool: bool = False
2130    #: Smoke runs one team may spend per authoring window when uploads are
2131    #: reviewed automatically (#1965 C). Public, because an author — an
2132    #: agent on a VPS, mostly — plans its uploads around it.
2133    authoring_smoke_quota: int = 10
2134    #: Whether an upload here joins the public catalog the moment it is
2135    #: published (#1989), rather than only the competitions that curate it.
2136    #: Public, because an author decides where to upload knowing whether
2137    #: their challenge goes in front of everybody. ``false`` from an older
2138    #: server — which did publish to everybody, and is the one reading this
2139    #: field cannot give.
2140    authoring_public_on_publish: bool = False
2141    #: Whether an upload here is smoked automatically as it arrives (#2721).
2142    #: Public, because an author learns from it whether a verdict will be
2143    #: waiting or a reviewer reads the upload first. ``true`` from an older
2144    #: server, which is what it did.
2145    authoring_auto_smoke: bool = True
2146    #: Whether an author here reads the model review of their own upload —
2147    #: its verdict and findings (#3050). Public, because the author page
2148    #: offers the review only where the event shows it. ``false`` from an
2149    #: older server, which showed it nowhere.
2150    authoring_authors_read_model_review: bool = False
2151    #: The pause after repeated rejections uploads here meet (#1965 C3), as
2152    #: stored — ``default`` follows the review mode. Public, because an
2153    #: author plans around it; the resolved rule and the caller's own pause
2154    #: are ``GET /competitions/{id}/authoring/pause``. ``default`` from an
2155    #: older server, which is what it did.
2156    authoring_cooldown: AuthoringCooldown = "default"
2157    authoring_cooldown_threshold: int = 3
2158    authoring_cooldown_window_s: int = 7 * 24 * 3600
2159    authoring_cooldown_pause_s: int = 7 * 24 * 3600
2160    # Registration window + form config. The window is genuinely public
2161    # information — an entrant needs to know when sign-ups close, and it
2162    # differs from the play window — and the rest lets the admin edit form
2163    # prefill without a second fetch. Nothing here is sensitive: the same
2164    # field policy is already served to any signed-in user by
2165    # ``GET /competitions/{id}/registration``.
2166    registration_starts_at: datetime | None = None
2167    registration_ends_at: datetime | None = None
2168    max_team_size: int = 0
2169    registration_review: bool = False
2170    #: Whether only an ``approved`` team may play and rank here (#2223) -- the
2171    #: stored switch. ``review_gates_play`` below is what it *means*.
2172    play_requires_approval: bool = False
2173    #: Whether a captain may no longer rename once the event has started
2174    #: (#2238); a board freeze locks renames whatever this says.
2175    team_rename_locked_after_start: bool = True
2176    #: ``registration_review and play_requires_approval``, composed here
2177    #: (``core.competitions.review_gates_play``) so no client restates the
2178    #: pair. Public: an entrant must be able to read the entry rule.
2179    review_gates_play: bool = False
2180    #: The institutions that may register, named from the catalogue (#2184);
2181    #: ``[]`` = open to everyone. Public: the list card and the detail page
2182    #: tell an ineligible person what to verify, and a competition that hid
2183    #: its own entry rule would send them to find out by being refused.
2184    eligible_institutions: list[EligibleInstitution] = Field(default_factory=list)
2185    #: The organiser's own list, as stored (#2767): ``eligible_institutions``
2186    #: is the *effective* set, which in ``mainland_student`` mode with no list
2187    #: is the whole catalogue -- so the settings card reads this one, or its
2188    #: first save would write the catalogue back as an explicit list.
2189    eligible_domains: list[str] = Field(default_factory=list)
2190    eligibility_mode: Literal["domains", "mainland_student"] = "domains"
2191    eligibility_carsi_roles: list[str] = Field(default_factory=list)
2192    team_entity_policy: Literal["none", "same_institution"] = "none"
2193    #: Whether the school rule admits students only (#2293). Public, like the rule.
2194    eligibility_students_only: bool = False
2195    #: The registration conditions (#2767). Public, like the rest of the rule:
2196    #: an entrant must be able to read why a team of one cannot register.
2197    min_team_size: int = 0
2198    on_ineligible: Literal["refuse", "review"] = "refuse"
2199    eligibility_exempt_invited: bool = True
2200    #: A verified school email satisfies the role block (#2932). Public: the
2201    #: registration page's rule sentence says so.
2202    eligibility_email_satisfies_role: bool = False
2203    #: Places (#2767 PR 6). Public like the rest of the rule; the live count is
2204    #: the eligibility card's, per viewer.
2205    registration_capacity: int = 0
2206    capacity_counts: CapacityCounts = "submitted"
2207    at_capacity: AtCapacity = "stop"
2208    registration_fields: dict[str, RegistrationFieldPolicy] = Field(default_factory=dict)
2209    # Public deliberately: how long entrants' personal data is kept is
2210    # part of the notice they are owed, not an internal setting.
2211    pii_retention_days: int = 0
2212    # Which rule ranks this board, and its tuning. Empty name means the
2213    # platform default (``flag_count``). Public because the players are
2214    # entitled to know how they are being scored.
2215    scoring_rule: ScoringRuleName | Literal[""] = ""
2216    scoring_params: dict[str, Any] = Field(default_factory=dict)
2217    #: What a multi-flag challenge is worth here (#2731) — public for
2218    #: ``scoring_rule``'s reason: a player reading the board is owed how a
2219    #: capture is priced. ``legacy`` on every event that predates it.
2220    scoring_rule_version: ScoringRuleVersion = LEGACY_SCORING_RULE_VERSION
2221    # Silent exercise mode, orthogonal to ``scoring_rule`` on purpose.
2222    # ``CompetitionState.silent`` is the derivation.
2223    silent: bool = False
2224    # Whether an entrant here may withhold their identity, and what
2225    # withholding looks like. Public for the reason ``scoring_rule``
2226    # above it is: somebody deciding whether to enter is entitled to
2227    # know, and the admin edit form prefills from this row.
2228    #
2229    # ⚠️ Defaults ``forbidden`` here too, unlike ``agents_allowed``
2230    # directly above. That one is a *door* and gates fail open; this is
2231    # a *promise*, and a client reading an older server's silence as
2232    # "anonymity is on offer" would show a control that stores nothing —
2233    # the worst failure this feature has, since the entrant believes
2234    # they answered.
2235    anonymous_entry: AnonymityPolicy = "forbidden"
2236    anonymous_entry_kind: AnonymityKind = "pseudonymous"
2237    # Server-derived: whether a scoring change is now a mid-event change —
2238    # allowed, but it needs a reason and is recorded and announced
2239    # (#1965 H). The admin form asks for the reason off this rather than
2240    # re-deriving the draft/phase rule client-side and disagreeing with
2241    # the server.
2242    #: ⚠️ The record itself is not here: a listing builds one of these per
2243    #: competition, and a read per row is the fan-out this repository has
2244    #: fixed four times. The board's banner and an agent read
2245    #: ``GET /competitions/{id}/scoring-changes``.
2246    scoring_live: bool = False
2247    # Server-derived projection. ``"upcoming"`` / ``"running"`` / ``"past"``
2248    # — saves the frontend from re-implementing the same string-compare
2249    # the backend already does.
2250    phase: Literal["upcoming", "running", "past"] = "running"
2251    # Declared play sessions, empty for a single-window competition.
2252    # Public: an entrant has to know the event runs 08:00–16:00 daily,
2253    # and the schedule is the first thing a competition page shows.
2254    sessions: list[CompetitionSessionInfo] = Field(default_factory=list)
2255    # Server-derived: whether play is open *right now*. Deliberately not
2256    # folded into ``phase`` — a competition between its 16:00 and the
2257    # next 08:00 is still running (board live, registration open, scoring
2258    # window unmoved), and re-classifying it would change what every
2259    # existing ``phase`` consumer says about a live event.
2260    #
2261    # ``True`` for every competition with no sessions, which is the whole
2262    # existing corpus: they are gated by nothing, so play is never shut.
2263    play_open: bool = True
2264    # When play reopens. ``None`` means there is no next session — either
2265    # none are declared, or the last one has ended. The closed-for-now
2266    # card counts down to this, so it is the one field it needs.
2267    next_session_starts_at: datetime | None = None
2268    # When the current session closes. ``None`` outside a session, and
2269    # also for a competition that declares none — there is no session to
2270    # name, and reporting the window's end here would put a "session ends
2271    # at" countdown on an event that never had sessions.
2272    current_session_ends_at: datetime | None = None
2273    # How long after the event the engagement report stays writable.
2274    # Public for the same reason ``sessions`` is: a team writing one has
2275    # to know when it locks. ``0`` means it closes with the event.
2276    report_window_s: int = 0
2277    # Server-derived: when the report window shuts. ``None`` for an
2278    # open-ended competition — there is no instant to count from, and a
2279    # fabricated deadline is worse than none.
2280    #
2281    # ⚠️ Deliberately *not* a ``report_open`` bool. That answer moves with
2282    # the clock, so a cached page would assert a state that has since
2283    # changed; an instant stays true and the client counts down from it.
2284    report_closes_at: datetime | None = None
2285    # Whether this competition runs a classic AWD tick loop. Server-derived
2286    # from the ``awd_matches`` side table through ``core.awd.is_awd_match``,
2287    # never re-derived per client: it decides which board a player is sent
2288    # to (``/awd`` vs ``/scoreboard``) and whether a gamebox page exists at
2289    # all, so two clients answering it differently is two clients
2290    # disagreeing about how the event is played.
2291    #
2292    # A bool rather than a ``format`` enum on purpose. ``docs/awd-plan.md``
2293    # §14.1 refuses a ``GameMode`` taxonomy at N=2 — a format is a point on
2294    # six orthogonal axes, not a subclass — and this field claims only the
2295    # fact the side table already stores. ``False`` is every competition
2296    # that predates AWD, which is the whole existing corpus.
2297    is_awd_match: bool = False
2298    # Whether this competition's standings live in the match rather than
2299    # in the scoring rule — ``is_awd_match and not challenge_ids``.
2300    #
2301    # ⚠️ **Not a rename of ``is_awd_match``, and the conjunction is the
2302    # whole field.** A competition may run a match *and* hang challenges
2303    # (``UserCompetition.is_awd_match``'s own comment anticipates the
2304    # mixed case), and that competition's Jeopardy board is real; keying
2305    # a board on the match alone would blank one with solves on it.
2306    #
2307    # ⚠️ It exists because the failure is **not** the empty board it
2308    # looks like. Measured on a pure rounds match with three settled
2309    # rounds: ``/scoreboard`` answers with every team at zero and ranks
2310    # complete, so the order falls to the tie break and puts the team
2311    # that is *losing* on top, while ``/awd/board`` carries the real one.
2312    # An empty board reads as broken and gets investigated; a full board
2313    # where everybody has zero reads as "the match just started" — and it
2314    # names the wrong leader on a projector.
2315    # ``ctfy/tests/test_pure_rounds_match_standings.py`` keeps that
2316    # measurement as a test rather than as this paragraph.
2317    #
2318    # ⚠️ Composed here rather than ``&&``-ed in the browser, for the
2319    # reason ``expects_report`` is: two clients composing it differently
2320    # is two clients disagreeing about where an event's standings are.
2321    # The wall and the leaderboard both need it, which is already two.
2322    ranks_by_match: bool = False
2323    #: The compact round clock, present only for an ``is_awd_match``
2324    #: competition that has a scheduled match, ``None`` otherwise. Lets a
2325    #: list card and the detail header draw "round N / M · mm:ss left"
2326    #: without a per-row ``/awd/match`` fetch. See ``AwdClockBrief``.
2327    #:
2328    #: ⚠️ ``None`` on a match with no ``starts_at`` (unscheduled) as well
2329    #: as on a non-match — the card reads its absence as "no round bar",
2330    #: never as round zero.
2331    awd_clock: AwdClockBrief | None = None
2332    # Whether this board puts a value on individual challenges —
2333    # ``ctfy.server.scoring.prices_challenges`` of this competition's own
2334    # rule.
2335    #
2336    # ⚠️ **An axis, so the wall's scene registry can ask it.** The set of
2337    # scenes a wall rotates through is a property of the *format*, never
2338    # of the data: a panel that appeared the moment somebody solved
2339    # something would re-map every viewer's digit keys mid-event.
2340    #
2341    # ⚠️ **Composed here so no client names rules.** `scoring_rule in
2342    # {"static_points", "dynamic_points"}` is the shape that writes
2343    # itself in a component, and it drops the panel from an AWD+ board —
2344    # whose attack half prices exactly like a points board — and goes
2345    # stale the day a sixth rule is registered.
2346    prices_challenges: bool = False
2347    # Whether every team owes this competition a written engagement
2348    # report — ``core.competitions.expects_report``, the same verdict
2349    # ``CompetitionMembershipInfo.expects_report`` already carries for
2350    # the sidebar.
2351    #
2352    # ⚠️ **Denormalized rather than composed by the client**, even though
2353    # both halves of the disjunction (``silent`` and ``scoring_rule``)
2354    # are already on this model. That is exactly what makes a `||` in a
2355    # component tempting, and it would be the *third* place the rule is
2356    # spelled — after which two clients disagree about whether an event
2357    # has a deliverable. Same argument that put ``is_awd_match`` and
2358    # ``can_replay`` here rather than in a page.
2359    #
2360    # ⚠️ **Needed on the *public* model, not just the membership one**,
2361    # because the sidebar entry it already feeds is membership-gated —
2362    # correctly, since that block is the registered player's nav. So a
2363    # reader deciding whether to enter had no surface anywhere saying
2364    # this event is graded on a written report, which is the one thing
2365    # about the exercise format they most need before they sign up.
2366    #
2367    # Defaults ``False``: reading a missing field as true hangs a report
2368    # card on every Jeopardy competition, which is the claim the
2369    # player-side link waited to be able to make.
2370    expects_report: bool = False
2371    #: Which kind of event this is, read off its axes on every request
2372    #: (``core.competition_formats.format_of``, #2006) — never stored, so it
2373    #: cannot outlive an edit to the switches that made it. Defaults to
2374    #: ``custom``, the reading that claims nothing: an older server sends
2375    #: no field, and a client must not invent a kind for it.
2376    format: CompetitionFormat = "custom"
2377    #: What an organiser should know about this combination of axes
2378    #: (``format_advisories``). Advice for the organiser's pages, never a
2379    #: refusal; every combination listed is storable on purpose.
2380    format_advisories: list[CompetitionFormatAdvisory] = Field(default_factory=list)
2381    #: Why an invitation to this competition cannot be acted on yet, or
2382    #: ``""`` — the ``core.competitions.invitation_block`` verdict.
2383    #:
2384    #: ⚠️ **The organiser's half of a fact the invitee already gets.**
2385    #: An invitation grants ``access`` and says nothing about ``status``,
2386    #: so one written against a draft is stored, correct and inert. The
2387    #: invitee's inbox says so; the organiser's roster did not, and the
2388    #: roster is the surface where they form a belief about it — every
2389    #: invitee sits at *invited* for ever because nobody **can** register,
2390    #: which reads as a field that is not responding and invites the
2391    #: wrong action (chase them) over the right one (publish).
2392    #:
2393    #: ⚠️ **Composed server-side though ``status`` is on the wire beside
2394    #: it**, for ``expects_report``'s reason: two clients deriving it
2395    #: differently is two clients disagreeing about whether an organiser's
2396    #: invitations are going anywhere. And it is the *same producer* the
2397    #: invitee's card reads, so the two people waiting on each other
2398    #: cannot be told different things — ``AuthoredChallengeInfo.curation``
2399    #: is the precedent, and like that one it is asserted by driving both
2400    #: routes in one test rather than by trusting the shared call.
2401    #:
2402    #: ⚠️ Always ``""`` for anybody who is not an admin here, and that is
2403    #: not a reason to move it to an admin model: a non-admin can only
2404    #: resolve a competition that *is* published, so the empty answer is
2405    #: the true one rather than a withheld one.
2406    invitations_blocked: InvitationBlock = ""
2407    #: Pinned by a platform admin (#2070): ordering and a marker, **never
2408    #: visibility** — a pinned draft is still a 404 to a player. Public: every
2409    #: viewer reads the same value, so it is a fact about the row.
2410    pinned: bool = False
2411    #: When it was pinned — the order between two pins (newest first).
2412    pinned_at: datetime | None = None
2413    #: ⚠️ **The viewer's own star, and the one field here that is not a fact
2414    #: about the row** (#2070). Stamped per request from ``favourites`` and
2415    #: ``False`` for an anonymous reader; nothing shared (a cache, a board)
2416    #: may carry it. Another person's star is never on the wire.
2417    favourited: bool = False
2418    # The competition this one is a *virtual sitting* of, empty for a
2419    # real one.
2420    #
2421    # On the wire because the marker is a rendering decision: the row's
2422    # ``title`` is the source's verbatim, deliberately, so a "(virtual)"
2423    # suffix baked into the database would be untranslatable English in
2424    # every locale's listing. A client reads this and labels it itself.
2425    virtual_of: str = ""
2426    # Whether this contest can be replayed on your own clock — the fact
2427    # a client renders its "Replay" button from, so it does not have to
2428    # POST and read a 400 to find out.
2429    #
2430    # Derived server-side from ``core.virtual.can_replay``, never per
2431    # client, for the same reason ``is_awd_match`` is: a client working
2432    # the refusal set out itself is a second implementation of
2433    # ``plan_virtual_run``, and it would drift once, silently, into a
2434    # button that fails the moment it is pressed.
2435    #
2436    # ⚠️ Deliberately **not** viewer-scoped, unlike ``can_participate``.
2437    # "Am I signed in" the client already knows, and "have I already
2438    # started one" is answered by the route being idempotent — asking it
2439    # here would put a per-user allowlist read inside a projection that
2440    # runs once per row of the listing, which is the N+1 shape three
2441    # other fields on this model have already had to have removed.
2442    # Defaults ``False``: a button that always 400s is worse than none.
2443    can_replay: bool = False
2444    # Total registered teams. Surfaced on cards so users can gauge
2445    # popularity at a glance.
2446    registered_count: int = 0
2447    # Viewer-scoped fields — see class docstring.
2448    is_registered: bool = False
2449    # Whether the viewer may register for this comp. Always ``True`` for
2450    # ``public`` comps; for private comps it's ``True`` only when the
2451    # viewer is invited or can administer it. Lets the frontend render a
2452    # "private — invitation required" locked card without a probe POST.
2453    # Defaults ``True`` so public / anonymous-on-public responses are open.
2454    can_participate: bool = True
2455    my_team_id: str = ""
2456    my_team_name: str = ""
2457    my_role: Literal["captain", "member", ""] = ""
2458    #: The viewer's own team's eligibility verdict, and the organiser's note
2459    #: on it (#2223). ``""`` = no team (or no review here) -- *not disclosed*,
2460    #: never a claim of approval. Only the team's own members are stamped.
2461    my_review_status: RegistrationReviewStatus | Literal[""] = ""
2462    my_review_note: str = ""
2463    #: Whether the viewer's team is kept out of play by that verdict
2464    #: (``core.competitions.review_blocks_play``). ⚠️ Read ``=== true`` in a
2465    #: client: absent is *not blocked*, as every gate here fails open.
2466    review_blocks_play: bool = False
2467    my_rank: int = -1
2468    my_score: int = -1
2469    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
title_i18n: dict[str, 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
challenges_withheld: bool = False
challenge_count: int | None = None
bucket: str = ''
challenge_scope: Literal['set', 'catalog'] = 'set'
status: Literal['draft', 'published', 'archived'] = 'published'
access: Literal['public', 'private_listed', 'private_hidden'] = 'public'
series_id: str = ''
series_name: str = ''
kind: ctfy.core.enums.CompetitionKind = <CompetitionKind.EVENT: 'event'>
eval_standard: bool = False
corpus_sha: str = ''
source_name: str = ''
source_url: str = ''
license: str = ''
maintainer: str = ''
version_date: str = ''
ordering: list[BoardCriterion] = PydanticUndefined
agents_allowed: bool = True
authoring_enabled: bool = False
agent_pulse: bool = False
entrants_are_agents: bool = False
authoring_windows: list[AuthoringWindowInfo] = PydanticUndefined
authoring_pending_per_team: int = 0
authoring_queue_capacity: int = 0
max_rebuilds_per_challenge: int = 0
max_instances_per_team: int = 0
max_instances_per_team_effective: int = 0
authoring_rate_limit_count: int = 5
authoring_rate_limit_window_s: int = 7200
authoring_uploads_per_window: int = 0
report_disclosure: Literal['default', 'after_lock', 'own', 'own_and_board'] = 'default'
author_team_detail: Literal['off', 'after_end', 'always'] = 'off'
lock_challenge_versions: bool = False
pre_start_challenge_count_public: bool = False
flag_prefix: str = 'FLAG'
flag_prefix_locked: bool = False
challenge_versions_locked: bool = False
test_of: str = ''
standings_open: bool = True
authoring_terms_version: str = ''
authoring_terms_url: str = ''
organiser_contact_email: str = ''
registration_takes_agreement: bool
2052    @computed_field
2053    @property
2054    def registration_takes_agreement(self) -> bool:
2055        """Whether registering a team here includes its captain accepting
2056        the contributor agreement for the whole team (#1965 B) — composed
2057        server-side (`registration_takes_the_agreement`) so the wizard never
2058        keeps its own copy of the rule."""
2059        return registration_takes_the_agreement(
2060            authoring_enabled=self.authoring_enabled,
2061            terms_version=self.authoring_terms_version,
2062        )

Whether registering a team here includes its captain accepting the contributor agreement for the whole team (#1965 B) — composed server-side (registration_takes_the_agreement) so the wizard never keeps its own copy of the rule.

internet_policy: ctfy.core.enums.InternetPolicy = <InternetPolicy.ALLOW: 'allow'>
agent_internet: ctfy.core.enums.InternetAccess = <InternetAccess.DENY: 'deny'>
tcp_access: ctfy.core.enums.TcpAccess = <TcpAccess.TLS_SNI: 'tls_sni'>
session_end_mode: ctfy.core.enums.SessionEndMode = <SessionEndMode.DELETE: 'delete'>
session_end_grace_s: int = 900
solve_env_mode: ctfy.core.enums.SolveEnvMode = <SolveEnvMode.DESTROY: 'destroy'>
solve_grace_s: int = 900
after_close: Literal['stop', 'practice'] = 'stop'
practice_instance_cap: int = 1
practice_ttl_s: int = 3600
freeze_minutes: int = 0
freeze_auto_unfreeze: bool = False
hint_cost_mode: Literal['discount', 'fixed', 'record'] = 'discount'
effective_hint_cost_mode: Literal['discount', 'fixed', 'record'] = 'discount'
unreleased_pending: bool = False
freeze_at: datetime.datetime | None = None
unfrozen_at: datetime.datetime | None = None
board_frozen: bool = False
recap_published: bool = False
authoring_review: Literal['human', 'automatic'] = 'human'
dedicated_pool: bool = False
authoring_smoke_quota: int = 10
authoring_public_on_publish: bool = False
authoring_auto_smoke: bool = True
authoring_authors_read_model_review: bool = False
authoring_cooldown: Literal['default', 'platform', 'competition', 'off'] = 'default'
authoring_cooldown_threshold: int = 3
authoring_cooldown_window_s: int = 604800
authoring_cooldown_pause_s: int = 604800
registration_starts_at: datetime.datetime | None = None
registration_ends_at: datetime.datetime | None = None
max_team_size: int = 0
registration_review: bool = False
play_requires_approval: bool = False
team_rename_locked_after_start: bool = True
review_gates_play: bool = False
eligible_institutions: list[EligibleInstitution] = PydanticUndefined
eligible_domains: list[str] = PydanticUndefined
eligibility_mode: Literal['domains', 'mainland_student'] = 'domains'
eligibility_carsi_roles: list[str] = PydanticUndefined
team_entity_policy: Literal['none', 'same_institution'] = 'none'
eligibility_students_only: bool = False
min_team_size: int = 0
on_ineligible: Literal['refuse', 'review'] = 'refuse'
eligibility_exempt_invited: bool = True
eligibility_email_satisfies_role: bool = False
registration_capacity: int = 0
capacity_counts: Literal['submitted', 'approved'] = 'submitted'
at_capacity: Literal['stop', 'waitlist'] = 'stop'
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', 'authoring'], Literal['']] = ''
scoring_params: dict[str, typing.Any] = PydanticUndefined
scoring_rule_version: Literal['legacy', 'challenge_total'] = 'legacy'
silent: bool = False
anonymous_entry: Literal['forbidden', 'optional', 'required'] = 'forbidden'
anonymous_entry_kind: Literal['pseudonymous', 'hidden'] = 'pseudonymous'
scoring_live: 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
format: Literal['questions', 'engagement', 'practice', 'awd', 'authoring', 'custom'] = 'custom'
format_advisories: list[typing.Literal['silent_report_ungraded', 'report_closes_with_event', 'suite_has_window', 'suite_has_sessions', 'suite_has_registration', 'suite_is_silent', 'event_in_hosted_eval']] = PydanticUndefined
invitations_blocked: Literal['', 'not_open_yet', 'closed'] = ''
pinned: bool = False
pinned_at: datetime.datetime | None = None
favourited: bool = False
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_review_status: Union[Literal['pending', 'approved', 'rejected'], Literal['']] = ''
my_review_note: str = ''
review_blocks_play: bool = False
my_rank: int = -1
my_score: int = -1
my_solves: int = -1
class CompetitionInternetApplied(ctfy.core.models.CtfyModel):
526class CompetitionInternetApplied(CtfyModel):
527    """What applying the policy to the running instances did (#2067).
528
529    One result per box whose verdict differed; ``changed`` + ``failed`` is
530    their count. A box already at its verdict is not listed — nothing was
531    sent for it.
532    """
533
534    competition_id: str
535    results: list[InstanceInternetResult] = Field(default_factory=list)
536    changed: int = 0
537    failed: int = 0

What applying the policy to the running instances did (#2067).

One result per box whose verdict differed; changed + failed is their count. A box already at its verdict is not listed — nothing was sent for it.

competition_id: str = PydanticUndefined
results: list[InstanceInternetResult] = PydanticUndefined
changed: int = 0
failed: int = 0
class CompetitionInternetApplyRequest(ctfy.core.models.CtfyModel):
520class CompetitionInternetApplyRequest(CtfyModel):
521    """Body of ``POST /admin/competitions/{id}/internet/apply`` (#2067)."""
522
523    reason: InternetChangeReason

Body of POST /admin/competitions/{id}/internet/apply (#2067).

reason: Annotated[str, StringConstraints(strip_whitespace=True, to_upper=None, to_lower=None, strict=None, min_length=1, max_length=500, pattern=None, ascii_only=None)] = PydanticUndefined
class CompetitionInternetInfo(ctfy.core.models.CtfyModel):
498class CompetitionInternetInfo(CtfyModel):
499    """``GET /admin/competitions/{id}/internet`` — the policy and every verdict.
500
501    The organiser's conflict list is ``[r for r in rows if r.conflict]``;
502    ``conflicts`` is its length, so a summary needs no second pass. Rows
503    with a conflict sort first.
504    """
505
506    competition_id: str
507    internet_policy: InternetPolicy
508    rows: list[ChallengeInternetRow] = Field(default_factory=list)
509    conflicts: int = 0
510    #: #2067: live instances here, and how many of them hold a verdict other
511    #: than the one the policy above gives today. The policy is read at
512    #: start, so a change leaves those boxes as they were until an organiser
513    #: applies it (``POST .../internet/apply``) or the box is reset. A box
514    #: whose challenge the catalog no longer holds has no declaration to
515    #: re-read and is counted in ``running`` only — never guessed at.
516    running: int = 0
517    running_differs: int = 0

GET /admin/competitions/{id}/internet — the policy and every verdict.

The organiser's conflict list is [r for r in rows if r.conflict]; conflicts is its length, so a summary needs no second pass. Rows with a conflict sort first.

competition_id: str = PydanticUndefined
internet_policy: ctfy.core.enums.InternetPolicy = PydanticUndefined
rows: list[ChallengeInternetRow] = PydanticUndefined
conflicts: int = 0
running: int = 0
running_differs: int = 0
class CompetitionInviteInfo(ctfy.core.models.CtfyModel):
3051class CompetitionInviteInfo(CtfyModel):
3052    """One participation-allowlist entry, for the admin "Invitations"
3053    card on a private competition. The body for POST is the shared
3054    ``GrantCompetitionAdminRequest`` (resolve by id or email).
3055
3056    ⚠️ **An invitation is a funnel, not a fact, and the card used to show
3057    only its first step.** The organiser's question — the one
3058    :class:`~ctfy.core.state.models.CompetitionEmailInviteState`'s own
3059    docstring says the claimed row is kept to answer — is "who did I
3060    invite that never turned up", and being invited, holding an account
3061    and actually registering a team are three different answers. The row
3062    carried the first: a name, a timestamp and a boolean, so somebody who
3063    signed in and then never registered was indistinguishable from
3064    somebody fully in. :attr:`status` is that axis, and the three
3065    ``*_at`` stamps below are the moment each stage was reached.
3066    """
3067
3068    user_id: str
3069    display_name: str = ""
3070    email: str = ""
3071    invited_by: str = ""
3072    #: Resolved from :attr:`invited_by`, because the raw id names nobody.
3073    #: ``""`` when the inviter's account is gone, or when the row was
3074    #: written by the *claim* path, which has no admin to name.
3075    invited_by_name: str = ""
3076    invited_at: datetime | None = None
3077    #: How far this invitation has got. See
3078    #: :data:`~ctfy.core.enums.CompetitionInviteStatus` for why the
3079    #: default is the weakest of the three.
3080    status: CompetitionInviteStatus = "pending_signup"
3081    #: When the invitation found its person — the moment an address with
3082    #: no account was claimed by a *verified* sign-in. Absent for
3083    #: somebody invited by id, who already had an account: there is no
3084    #: such moment for them, and their own registration date is not it.
3085    signed_up_at: datetime | None = None
3086    #: The team they registered with for *this* competition, and when.
3087    #: Empty on any row that has not reached ``registered``.
3088    team_id: str = ""
3089    team_name: str = ""
3090    registered_at: datetime | None = None
3091    #: The organiser's free-text note from when they invited, or ``""``.
3092    #: Shown on the roster so "why is this person on the list" is
3093    #: answerable without leaving the card.
3094    reason: str = ""
3095    #: The team a batch row named for this address, and what claiming did
3096    #: about it (#2078). ``placement`` is ``""`` until the address is proved;
3097    #: anything but ``placed`` is the organiser's cue to act.
3098    intended_team_name: str = ""
3099    intended_team_captain: bool = False
3100    placement: InvitePlacementOrUnset = ""
3101
3102    @computed_field
3103    @property
3104    def pending_signup(self) -> bool:
3105        """True while the invitation is still waiting for its person: the
3106        address was invited before anyone signed up with it, so there is
3107        no account to name yet. :attr:`user_id` is empty in that state,
3108        which is why the card keys those rows on :attr:`email`.
3109
3110        ⚠️ **Computed from :attr:`status` rather than carried beside it.**
3111        It was a stored field first and the status axis subsumes it, so
3112        the two could disagree — and the client uses this one to pick
3113        which of the two revoke routes to call, keyed differently
3114        (address vs id). A row that said ``pending_signup: false`` while
3115        its status said otherwise would DELETE ``.../invites/`` with an
3116        empty id. A ``computed_field`` because this is a *wire* model, per
3117        the split ``LlmBudgetRow.exhausted`` records.
3118        """
3119        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 = ''
intended_team_name: str = ''
intended_team_captain: bool = False
placement: Union[ctfy.core.enums.InvitePlacement, Literal['']] = ''
pending_signup: bool
3102    @computed_field
3103    @property
3104    def pending_signup(self) -> bool:
3105        """True while the invitation is still waiting for its person: the
3106        address was invited before anyone signed up with it, so there is
3107        no account to name yet. :attr:`user_id` is empty in that state,
3108        which is why the card keys those rows on :attr:`email`.
3109
3110        ⚠️ **Computed from :attr:`status` rather than carried beside it.**
3111        It was a stored field first and the status axis subsumes it, so
3112        the two could disagree — and the client uses this one to pick
3113        which of the two revoke routes to call, keyed differently
3114        (address vs id). A row that said ``pending_signup: false`` while
3115        its status said otherwise would DELETE ``.../invites/`` with an
3116        empty id. A ``computed_field`` because this is a *wire* model, per
3117        the split ``LlmBudgetRow.exhausted`` records.
3118        """
3119        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 CompetitionMachineRow(ctfy.core.models.CtfyModel):
28class CompetitionMachineRow(CtfyModel):
29    """One machine as the organiser sees it. No secret, ever."""
30
31    id: str
32    host: str
33    port: int
34    username: str
35    note: str = ""
36    #: The team *name* the CSV pinned this machine to, or ``""``.
37    pin: str = ""
38    team_id: str = ""
39    team_name: str = ""
40    status: MachineStatus
41    has_password: bool = False
42    has_private_key: bool = False
43    assigned_at: datetime | None = None
44    notified_at: datetime | None = None
45    viewed_at: datetime | None = None
46    retired_at: datetime | None = None
47    created_at: datetime | None = None

One machine as the organiser sees it. No secret, ever.

id: str = PydanticUndefined
host: str = PydanticUndefined
port: int = PydanticUndefined
username: str = PydanticUndefined
note: str = ''
pin: str = ''
team_id: str = ''
team_name: str = ''
status: Literal['unassigned', 'assigned', 'retired'] = PydanticUndefined
has_password: bool = False
has_private_key: bool = False
assigned_at: datetime.datetime | None = None
notified_at: datetime.datetime | None = None
viewed_at: datetime.datetime | None = None
retired_at: datetime.datetime | None = None
created_at: datetime.datetime | None = None
class CompetitionMachines(ctfy.core.models.CtfyModel):
61class CompetitionMachines(CtfyModel):
62    competition_id: str
63    machines: list[CompetitionMachineRow] = Field(default_factory=list)
64    total: int = 0
65    unassigned: int = 0
66    assigned: int = 0
67    retired: int = 0
68    #: Human teams (not disqualified) with no live machine: what the
69    #: auto-assign button would still serve.
70    teams_without: int = 0
71    #: Every team of the competition, in registration order, for the
72    #: manual-assign picker.
73    teams: list[MachineTeamOption] = 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.

competition_id: str = PydanticUndefined
machines: list[CompetitionMachineRow] = PydanticUndefined
total: int = 0
unassigned: int = 0
assigned: int = 0
retired: int = 0
teams_without: int = 0
teams: list[MachineTeamOption] = PydanticUndefined
class CompetitionMembershipInfo(ctfy.core.models.CtfyModel):
181class CompetitionMembershipInfo(CtfyModel):
182    """One row on ``MeResponse.competition_teams``.
183
184    Lets the frontend hydrate a per-comp team picker / "My
185    competitions" hub from a single ``/me`` call without N+1
186    fetches. Captain status surfaces in the UI for buttons gated
187    on ``role == "captain"`` (mint invite / kick / rename).
188
189    ``competition_title`` / ``competition_phase`` are denormalized
190    from the competition the membership points at so the switcher
191    and the dashboard can label rows by the *competition* (the
192    thing the user is choosing between) rather than the team name —
193    every user is auto-joined to an identically named personal team
194    per comp, so team name alone makes the rows indistinguishable.
195    """
196
197    competition_id: str
198    competition_title: str
199    #: The competition's title per locale (#2230); a browser renders this,
200    #: ``competition_title`` is its base-locale reading.
201    competition_title_i18n: LocalizedText = Field(default_factory=dict)
202    competition_phase: Literal["upcoming", "running", "past"]
203    #: Hosted event or benchmark suite (#2097). ⚠️ Rows are *not* dropped for
204    #: a suite: the live-event channel is addressed by team, and a person who
205    #: practised on a suite must still hear their own team's frames. The rail
206    #: and the home page list the ``event`` rows only; this is how they tell.
207    #: Defaults ``event``, so an older server leaves every row where it was.
208    kind: CompetitionKind = CompetitionKind.EVENT
209    team_id: str
210    team_name: str
211    role: Literal["captain", "member"]
212    # Whether this competition runs a classic AWD tick loop — the same
213    # fact ``CompetitionInfo.is_awd_match`` carries, denormalized here
214    # for the one reader that cannot fetch it. ``Sidebar`` is mounted
215    # above the routes while ``CompetitionContext`` lives inside
216    # ``CompetitionLayout``, so the nav cannot see the competition the
217    # layout already loaded; it *can* see this, through ``AuthContext``,
218    # with no request of its own. Without it the per-competition block
219    # sends every AWD player to the Jeopardy board, which a match never
220    # populates.
221    is_awd_match: bool = False
222    # Whether this competition runs silent (§4b) — the same fact
223    # ``CompetitionInfo.silent`` carries, denormalized here for the same
224    # reader and the same reason ``is_awd_match`` is: ``Sidebar`` is
225    # mounted above the routes, so it cannot see the competition
226    # ``CompetitionLayout`` already loaded, and it *can* see this through
227    # ``AuthContext`` with no request of its own.
228    #
229    # ⚠️ Without it the per-competition nav offers a Scoreboard entry
230    # for the whole of a silent engagement, and every standing route
231    # behind it refuses — a link that cannot work, for the one format
232    # where "how am I doing" is the question the platform exists to
233    # decline.
234    silent: bool = False
235    # Whether this event's standings are open to its players now —
236    # ``core.competitions.standings_open`` (#2030). Always true for a loud
237    # event; a silent one opens when its organiser chose ``own_and_board``
238    # or once its report locks. The scoreboard entry reads this rather than
239    # ``silent``, which says what the format is, not what is shown.
240    standings_open: bool = True
241    # Whether this competition expects an engagement report — the
242    # ``core.competitions.expects_report`` verdict, denormalized here for
243    # the same reader and the same reason the two flags above are.
244    #
245    # ⚠️ **Not the same question as ``silent``**, and reusing that one is
246    # the tempting mistake in both directions: a *loud* exercise
247    # competition grades reports and would get no link, while the
248    # disjunction is what a nav entry is entitled to claim. It is derived
249    # server-side rather than ``||``-ed by each client for the reason
250    # ``is_awd_match`` is: two clients composing it differently is two
251    # clients disagreeing about whether this event has a deliverable.
252    expects_report: bool = False
253    # How many of this team's current write-ups this competition still
254    # owes a verdict on — the cross-competition half of the report
255    # signal, which no other surface could carry.
256    #
257    # ⚠️ **On the membership, not on ``/meta``'s per-caller block.** The
258    # rail draws one tile per competition and every one of its signals
259    # comes off this single payload with no per-tile fetch, so a number
260    # here reaches a player standing anywhere on the platform — including
261    # below ``lg``, where the status bar is ``display: none`` and a chip
262    # there would have reached nobody on a phone. Measured before
263    # choosing: the bar has 167px spare for a member in a competition
264    # (``frontend/e2e/measure-status-bar.spec.ts``), so a chip was
265    # affordable and still wrong.
266    #
267    # ⚠️ **Gated on the competition being *scored*, never on
268    # ``expects_report``.** That one is ``silent OR exercise``, so a
269    # silent ``flag_count`` event expects a report and grades it never —
270    # a badge keyed on it would sit on such a tile for the life of the
271    # event, counting a verdict nobody is coming to give. The same
272    # distinction ``ReportCta`` makes by rendering no chip for
273    # ``not_scored``.
274    #
275    # ⚠️ **A count, and deliberately not a status.** ``waiting`` and
276    # ``queued`` are one fact from here (*they are holding our work*) and
277    # the difference between them is a question the report index answers
278    # with a queue position, one click away. Compressing two states into
279    # one badge is what would make the badge disagree with the page it
280    # links to.
281    #
282    # Defaults ``0``: an older server saying nothing must not paint a
283    # badge on every tile, which is the direction a wrong value is
284    # noticed in least.
285    reports_awaiting_verdict: int = 0
286    # Whether this competition asks entrants to write challenges — the
287    # same fact ``CompetitionInfo.authoring_enabled`` carries,
288    # denormalized here for the same reader and the same reason the
289    # three flags above are: ``Sidebar`` is mounted above the routes and
290    # can only see this through ``AuthContext``.
291    #
292    # ⚠️ Defaults **False**, so an older server offering no opinion
293    # leaves the entry absent. That is the opposite direction from every
294    # *gate* on this model, and it is right for the same reason
295    # ``CompetitionInfo.authoring_enabled`` defaults False: a nav entry
296    # is an offer, and offering one for a Jeopardy event tells its
297    # players there is an authoring surface the competition does not
298    # have.
299    authoring_enabled: bool = False
300    # Whether this competition has a rules page worth a nav entry —
301    # ``bool(CompetitionState.handbook)``, denormalized here for the
302    # same reader and the same reason ``is_awd_match`` is: ``Sidebar``
303    # is mounted above the routes and cannot see ``CompetitionLayout``'s
304    # already-fetched competition.
305    #
306    # ⚠️ **A boolean, never the text.** The handbook is capped at 20 000
307    # characters and ``/me`` carries one of these rows per competition
308    # the caller is in, so publishing the document here would put the
309    # whole set on every page load — ``AuthoredChallengeSummary``'s
310    # recorded defect through a second door.
311    #
312    # ⚠️ Defaults ``False`` for ``authoring_enabled``'s reason: an older
313    # server sends no field, and inventing a rules entry for every
314    # competition points a nav row at a page nobody has written.
315    has_handbook: bool = False
316    # Whether the competition carries any attachment (#1808) — the rules
317    # page's other content, so the nav entry shows for an event whose
318    # rules are only a file. Its own flag rather than folded into
319    # ``has_handbook``: a flag named for the wrong fact is shape 4.
320    # Defaults ``False`` for ``has_handbook``'s reason.
321    has_attachments: bool = False
322    # ⚠️ **The authoring *window* is deliberately not here.** The rail's
323    # only consumer is the nav entry, which must stay visible after the
324    # window closes — a verdict is read long after the last upload — so
325    # nothing would look at it, and `/me`'s producer would have to
326    # remember to fill two more fields per membership. Unset it is a
327    # claim ("this event has no window") on every competition that does.
328    # `CompetitionInfo` carries the pair, and the authoring page reads it
329    # off the competition it is already given.
330    # Rail-enrichment fields (denormalized so the Slack-style rail can
331    # render instance count / rank / a time-progress ring from one /me
332    # call, no per-tile fetch fan-out).
333    #
334    # Live instances for THIS team in THIS comp. Mirrors
335    # InstanceCountContext semantics (all non-terminal InstanceState
336    # rows for the team — terminal ones already left the live store as
337    # InstanceRecord); 0 when none.
338    running_instances: int = 0
339    # Competition window, copied straight off the CompetitionState the
340    # /me loop already fetches. "" start = live immediately, "" end =
341    # never ends (same convention as CompetitionState / phase()).
342    starts_at: datetime | None = None
343    ends_at: datetime | None = None
344    # The two facts a *pure rounds match* is read by — the same pair
345    # ``CompetitionInfo`` carries, from the same producers
346    # (``core.competitions.ranks_by_match`` and ``awd_clock_brief``).
347    #
348    # ⚠️ **The window above is not the event for such a competition.** The
349    # runbook's exhibition has no window of its own, so ``competition_
350    # phase`` is ``running`` from creation on; the rail read that and put
351    # a running pie on a tile three hours before kickoff, while the page
352    # it links to said 即将开始. The browser narrows the window to the
353    # match's (``frontend/src/lib/headlineWindow.ts``) — which it can only
354    # do if the kickoff and the last round's close reach it.
355    #
356    # ⚠️ Default ``False`` / ``None``: an older server sends neither, and
357    # the tile then reads the competition's own window exactly as it
358    # always did — the reading that claims nothing new.
359    ranks_by_match: bool = False
360    awd_clock: AwdClockBrief | None = None
361    # Caller's current rank in this comp. -1 sentinel = "not computed"
362    # (upcoming phase, or the per-request rank budget was exhausted) —
363    # matches the existing my_rank<0 frontend convention.
364    my_rank: int = -1
365    # Registered-team count, the denominator the UI shows as "#3 / 40".
366    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_title_i18n: dict[str, str] = PydanticUndefined
competition_phase: Literal['upcoming', 'running', 'past'] = PydanticUndefined
kind: ctfy.core.enums.CompetitionKind = <CompetitionKind.EVENT: 'event'>
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
role: Literal['captain', 'member'] = PydanticUndefined
is_awd_match: bool = False
silent: bool = False
standings_open: bool = True
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 CompetitionPinnedChallenges(ctfy.core.models.CtfyModel):
3288class CompetitionPinnedChallenges(CtfyModel):
3289    """The challenges an event's organisers pinned to the head of its list
3290    (#2070), in the order they are shown. Always a subset of the event's own
3291    set; the whole list is replaced, so unpinning is sending it without the id."""
3292
3293    challenge_ids: list[str] = Field(default_factory=list, max_length=2000)

The challenges an event's organisers pinned to the head of its list (#2070), in the order they are shown. Always a subset of the event's own set; the whole list is replaced, so unpinning is sending it without the id.

challenge_ids: list[str] = PydanticUndefined
class CompetitionPoolInfo(ctfy.core.models.CtfyModel):
607class CompetitionPoolInfo(CtfyModel):
608    """``GET /admin/competitions/{id}/pool`` — where this event's work runs (#1965 E).
609
610    ``state`` is ``shared`` for an event that declares no dedicated pool —
611    ``nodes`` then lists any machine labelled for it anyway, which is
612    idle — and otherwise ``ready`` / ``no_live_node`` by ``live_nodes``.
613    ⚠️ ``no_live_node`` means every start of the event is refused and every
614    smoke waits: the platform never falls back to the shared pools.
615    """
616
617    competition_id: str
618    dedicated: bool = False
619    state: DedicatedPoolState = "shared"
620    live_nodes: int = 0
621    #: Live isolated nodes (#3247). ⚠️ ``0`` in a dedicated pool means a
622    #: version released from a malice hold waits for its smoke — it never
623    #: runs on the event's own nodes.
624    live_isolated_nodes: int = 0
625    nodes: list[CompetitionPoolNode] = Field(default_factory=list)

GET /admin/competitions/{id}/pool — where this event's work runs (#1965 E).

state is shared for an event that declares no dedicated pool — nodes then lists any machine labelled for it anyway, which is idle — and otherwise ready / no_live_node by live_nodes. ⚠️ no_live_node means every start of the event is refused and every smoke waits: the platform never falls back to the shared pools.

competition_id: str = PydanticUndefined
dedicated: bool = False
state: Literal['shared', 'ready', 'no_live_node'] = 'shared'
live_nodes: int = 0
live_isolated_nodes: int = 0
nodes: list[CompetitionPoolNode] = PydanticUndefined
class CompetitionPoolNode(ctfy.core.models.CtfyModel):
584class CompetitionPoolNode(CtfyModel):
585    """One node labelled ``competition=<this event>`` (#1965 E), live or not."""
586
587    id: str
588    display_name: str = ""
589    #: Healthy and heard from within the heartbeat timeout.
590    alive: bool = False
591    #: An operator's cordon: takes no new box or smoke, keeps what it holds.
592    cordoned: bool = False
593    #: Counted in :attr:`CompetitionPoolInfo.live_nodes` — alive, uncordoned,
594    #: platform-owned (`scheduling.dedicated_pool_nodes`), so the page and
595    #: the dispatcher cannot count one pool two ways.
596    counted: bool = False
597    #: The event's **isolated** node (#3247): it also carries
598    #: ``quarantine_builds``, so it runs only the smoke of a version a person
599    #: released from a malice hold (and the AI pre-check) — never a player's
600    #: box — and is counted in :attr:`CompetitionPoolInfo.live_isolated_nodes`.
601    isolated: bool = False
602    #: Counted in :attr:`CompetitionPoolInfo.live_isolated_nodes`
603    #: (`scheduling.dedicated_isolated_nodes`).
604    counted_isolated: bool = False

One node labelled competition=<this event> (#1965 E), live or not.

id: str = PydanticUndefined
display_name: str = ''
alive: bool = False
cordoned: bool = False
counted: bool = False
isolated: bool = False
counted_isolated: bool = False
class CompetitionPreviewChallenges(ctfy.core.models.CtfyModel):
3296class CompetitionPreviewChallenges(CtfyModel):
3297    """An invitation-only suite's free sample challenges (#2306), in the
3298    order its page shows them. Always a subset of the suite's own set; the
3299    whole list is replaced, so ``[]`` offers none. Read only while the suite
3300    is not public: a public suite opens everything already."""
3301
3302    challenge_ids: list[str] = Field(default_factory=list, max_length=2000)

An invitation-only suite's free sample challenges (#2306), in the order its page shows them. Always a subset of the suite's own set; the whole list is replaced, so [] offers none. Read only while the suite is not public: a public suite opens everything already.

challenge_ids: list[str] = PydanticUndefined
class CompetitionPrewarm(ctfy.core.models.CtfyModel):
2736class CompetitionPrewarm(CtfyModel):
2737    """Whether a competition's images are warm on every node that could host
2738    it (#2129): the organiser's per node x challenge readiness, plus where the
2739    window stands.
2740
2741    ``target`` is the start the window is for (the next session's, or the
2742    competition's), ``None`` when none is ahead. ``started`` is true once a
2743    start has passed and the competition is still running -- the page then
2744    shows a warning for any row that is not ready. ``reason_unready`` is
2745    never a promise about the *version*: frozen images are tagged by content
2746    hash, so a node holding an older one cannot serve the new one -- not
2747    ready means a slow first start, never a wrong version.
2748    """
2749
2750    competition_id: str
2751    enabled: bool = True
2752    target: datetime | None = None
2753    window_opens_at: datetime | None = None
2754    window_open: bool = False
2755    started: bool = False
2756    last_pass_at: datetime | None = None
2757    final_done: bool = False
2758    rows: list[PrewarmRow] = Field(default_factory=list)
2759
2760    @computed_field
2761    @property
2762    def ready(self) -> int:
2763        return sum(1 for r in self.rows if r.state == PrewarmState.READY)
2764
2765    @computed_field
2766    @property
2767    def total(self) -> int:
2768        return len(self.rows)

Whether a competition's images are warm on every node that could host it (#2129): the organiser's per node x challenge readiness, plus where the window stands.

target is the start the window is for (the next session's, or the competition's), None when none is ahead. started is true once a start has passed and the competition is still running -- the page then shows a warning for any row that is not ready. reason_unready is never a promise about the version: frozen images are tagged by content hash, so a node holding an older one cannot serve the new one -- not ready means a slow first start, never a wrong version.

competition_id: str = PydanticUndefined
enabled: bool = True
target: datetime.datetime | None = None
window_opens_at: datetime.datetime | None = None
window_open: bool = False
started: bool = False
last_pass_at: datetime.datetime | None = None
final_done: bool = False
rows: list[PrewarmRow] = PydanticUndefined
ready: int
2760    @computed_field
2761    @property
2762    def ready(self) -> int:
2763        return sum(1 for r in self.rows if r.state == PrewarmState.READY)
total: int
2765    @computed_field
2766    @property
2767    def total(self) -> int:
2768        return len(self.rows)
class CompetitionRecapMail(ctfy.core.models.CtfyModel):
152class CompetitionRecapMail(CtfyModel):
153    """A competition's recap-email settings and its send log."""
154
155    competition_id: str
156    enabled: bool = False
157    language: RecapMailLanguage = "zh-CN"
158    recipients: list[RecapRecipientInfo] = Field(default_factory=list)
159    max_recipients: int = MAX_RECIPIENTS
160    #: The report due now (``None`` when none is), so the page can say
161    #: whether the schedule has sent it yet.
162    due: RecapDueInfo | None = None
163    #: Newest first, at most ``SEND_LOG_LIMIT``.
164    sends: list[RecapSendInfo] = Field(default_factory=list)

A competition's recap-email settings and its send log.

competition_id: str = PydanticUndefined
enabled: bool = False
language: Literal['zh-CN', 'en', 'both'] = 'zh-CN'
recipients: list[RecapRecipientInfo] = PydanticUndefined
max_recipients: int = 20
due: RecapDueInfo | None = None
sends: list[RecapSendInfo] = PydanticUndefined
class CompetitionRecapMailUpdate(ctfy.core.models.CtfyModel):
167class CompetitionRecapMailUpdate(CtfyModel):
168    """A partial update: ``None`` leaves a field as it is. Addresses are added
169    whole and removed by their ``id``; the page never holds the plaintext."""
170
171    model_config = ConfigDict(extra="forbid")
172
173    enabled: bool | None = None
174    language: RecapMailLanguage | None = None
175    add_recipients: list[Annotated[str, Field(max_length=254)]] | None = Field(
176        default=None, max_length=MAX_RECIPIENTS
177    )
178    remove_recipients: list[Annotated[str, Field(max_length=64)]] | None = Field(
179        default=None, max_length=MAX_RECIPIENTS
180    )

A partial update: None leaves a field as it is. Addresses are added whole and removed by their id; the page never holds the plaintext.

enabled: bool | None = None
language: Optional[Literal['zh-CN', 'en', 'both']] = None
add_recipients: list[typing.Annotated[str, FieldInfo(annotation=NoneType, required=True, metadata=[MaxLen(max_length=254)])]] | None = None
remove_recipients: list[typing.Annotated[str, FieldInfo(annotation=NoneType, required=True, metadata=[MaxLen(max_length=64)])]] | None = None
class CompetitionRecapSettings(ctfy.core.models.CtfyModel):
78class CompetitionRecapSettings(CtfyModel):
79    """What the organisers stated for this competition's recap."""
80
81    competition_id: str
82    #: ``0`` = no target stated.
83    registration_target: int = 0
84    #: ``""`` = none stated.
85    previous_edition_id: str = ""
86    #: The edition ``previous_edition_id`` names; ``None`` when none is
87    #: stated, or when the stored one is gone (deleted since).
88    previous_edition: CompetitionEditionRef | None = None
89    award_bands: list[AwardBandInfo] = Field(default_factory=list)
90    #: When the recap was published at ``/competitions/{id}/recap``;
91    #: ``None`` = not published (#3023 2e).
92    recap_published_at: datetime | None = None
93    #: Whether publishing is allowed now: only once the event has ended
94    #: (``core.recap.recap_publishable``). Withdrawing is always allowed.
95    recap_publishable: bool = False

What the organisers stated for this competition's recap.

competition_id: str = PydanticUndefined
registration_target: int = 0
previous_edition_id: str = ''
previous_edition: CompetitionEditionRef | None = None
award_bands: list[AwardBandInfo] = PydanticUndefined
recap_published_at: datetime.datetime | None = None
recap_publishable: bool = False
class CompetitionRecapSettingsUpdate(ctfy.core.models.CtfyModel):
 98class CompetitionRecapSettingsUpdate(CtfyModel):
 99    """A partial update: ``None`` leaves a field as it is."""
100
101    model_config = ConfigDict(extra="forbid")
102
103    registration_target: int | None = Field(default=None, ge=0, le=100000)
104    #: ``""`` clears the link.
105    previous_edition_id: str | None = None
106    award_bands: list[AwardBandInfo] | None = None
107    #: ``True`` publishes the recap (keeping the first publication time if it
108    #: is already public), ``False`` withdraws it.
109    recap_published: bool | None = None

A partial update: None leaves a field as it is.

registration_target: int | None = None
previous_edition_id: str | None = None
award_bands: list[AwardBandInfo] | None = None
recap_published: bool | None = None
class CompetitionRecords(ctfy.core.models.CtfyModel):
120class CompetitionRecords(CtfyModel):
121    """Everything a scoring script needs, in one file.
122
123    Lists are complete: an export is the whole event or it is not one.
124    """
125
126    competition_id: str
127    title: str = ""
128    scoring_rule: str = ""
129    exported_at: datetime
130    challenge_ids: list[str] = Field(default_factory=list)
131    #: What may be played there (``CompetitionState.challenge_scope``).
132    challenge_scope: ChallengeScope = "set"
133    #: ⚠️ **What the board ranks, which is not ``challenge_ids``** (#2553,
134    #: `authored_strays.board_scope`): the set, plus each id the platform took
135    #: out with its solves' points kept, minus a gated suite's free samples.
136    board_challenge_ids: list[str] = Field(default_factory=list)
137    #: For an id the platform took out of the set: the instant of its removal.
138    #: A solve of it counts on the board only when ``ranked_at`` is before it.
139    kept_points_until: dict[str, datetime] = Field(default_factory=dict)
140    #: Every recorded change to the set, oldest first — the source of
141    #: ``kept_points_until`` (the latest record of each challenge decides).
142    challenge_changes: list[RecordChallengeChange] = Field(default_factory=list)
143    teams: list[RecordTeam] = Field(default_factory=list)
144    solves: list[RecordSolve] = Field(default_factory=list)
145    submissions: list[RecordSubmission] = Field(default_factory=list)
146    eval_runs: list[RecordEvalRun] = Field(default_factory=list)
147    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
challenge_scope: Literal['set', 'catalog'] = 'set'
board_challenge_ids: list[str] = PydanticUndefined
kept_points_until: dict[str, datetime.datetime] = PydanticUndefined
challenge_changes: list[RecordChallengeChange] = 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):
2963class CompetitionRegistrationInfo(CtfyModel):
2964    """One row in the admin "who's registered" table."""
2965
2966    team_id: str
2967    team_name: str = ""
2968    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):
193class CompetitionRoster(CtfyModel):
194    """``GET /competitions/{id}/roster`` — every registered team with its
195    members, in one read.
196
197    Exists because the alternative is a fan-out: a caller that wants to
198    know who is playing has to call ``GET /teams/{id}`` once per team,
199    which for a 400-team event is 400 round trips for a page that has to
200    stay live. The backend answers it in three bulk queries whatever the
201    team count.
202
203    ⚠️ **Public identity only.** ``TeamMemberInfo`` carries
204    ``display_name`` / ``avatar_url`` and nothing else, which is exactly
205    what ``GET /teams/{id}`` already publishes per team. The entrant
206    dossier — ``real_name`` / ``phone`` / ``mailing_address``, 个人敏感信息
207    sealed at the backend boundary — reaches the reviewer roster
208    (``RegistrationTeamRow``) and nowhere else, and must never be added
209    here: this response is public and cacheable, so a field added to it
210    is a field published to everyone forever.
211    """
212
213    teams: list[CompetitionRosterTeam] = Field(default_factory=list)
214    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):
138class CompetitionRosterTeam(CtfyModel):
139    """One team plus its roster, for the competition-wide roster read."""
140
141    team_id: str
142    name: str = ""
143    captain_user_id: str = ""
144    members: list[TeamMemberInfo] = Field(default_factory=list)
145    #: See :attr:`ctfy.server.models.ScoreboardEntry.anonymous`.
146    anonymous: bool = False
147    #: The squad's institution badge — see :attr:`TeamInfo.affiliation`,
148    #: which is the same fact about the same subject.
149    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):
1465class CompetitionScoreDistribution(CtfyModel):
1466    """Aggregate companion to the (now server-paginated) competition
1467    scoreboard: the score-distribution histogram + the true team count.
1468
1469    The standings table pages server-side, so the histogram / "N teams"
1470    figure can't be re-derived from one page in the browser — this
1471    bins every registered team's ``flags_solved`` with the same
1472    adaptive scheme the frontend used to do client-side.
1473    """
1474
1475    total_teams: int = 0
1476    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):
1369class CompetitionScoreHistory(CtfyModel):
1370    """Top-N teams' score/rank progression, reconstructed from the
1371    competition's solve log (no snapshot dependency — exact for past
1372    and live competitions alike)."""
1373
1374    series: list[ScoreHistorySeries] = Field(default_factory=list)
1375    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):
39class CompetitionSeriesConfig(CtfyModel):
40    """What an organiser sets on a series."""
41
42    model_config = ConfigDict(extra="forbid")
43
44    name: str = ""
45    enabled: bool = False
46
47    weekday: int = 5
48    hour: int = 20
49    minute: int = 0
50    timezone: str = "UTC"
51    every_n_weeks: int = 1
52    first_at: AwareDatetime | None = None
53    lead_seconds: int = Field(default=172800, ge=0)
54
55    title_template: str = ""
56    description: str = ""
57    duration_seconds: int = Field(default=10800, ge=1)
58    pool: list[str] = Field(default_factory=list)
59    slate_size: int = Field(default=1, ge=1)
60    #: ⚠️ Typed like the two axes directly below it. It was the one
61    #: field on this model that was not, which is how a series could
62    #: spawn every occurrence under a rule the board cannot run.
63    scoring_rule: ScoringRuleName | Literal[""] = ""
64    scoring_params: dict[str, Any] = Field(default_factory=dict)
65    status: CompetitionStatus = "published"
66
67    @field_validator("scoring_rule")
68    @classmethod
69    def _ranks_a_competition(cls, rule: str) -> str:
70        """⚠️ A series spawns *competitions*, so a board-only rule (#1965 G,
71        ``BOARD_ONLY_RULES``) would spawn every occurrence ranked by what
72        its teams wrote — refused here, at the door that would store it."""
73        if rule in BOARD_ONLY_RULES:
74            raise ValueError(f"{rule} ranks a board, never a competition")
75        return rule
76
77    access: CompetitionAccess = "public"
78    #: Seconds after each occurrence's ``ends_at`` that its report stays
79    #: open. ⚠️ Without it an ``exercise`` series was unofferable — every
80    #: occurrence took the platform default of ``0``, which means the
81    #: report closes with the event.
82    report_window_s: int = Field(default=0, ge=0)
83
84    @model_validator(mode="after")
85    def _cadence_is_expressible(self) -> CompetitionSeriesConfig:
86        """⚠️ Validated through `Cadence`, never re-checked here.
87
88        Two copies of "is this a legal weekday / a real timezone" is how
89        the form and the job come to disagree about which Saturdays
90        exist — and the job's copy is the one nobody sees fail.
91        """
92        Cadence(
93            weekday=self.weekday,
94            hour=self.hour,
95            minute=self.minute,
96            timezone=self.timezone,
97            every_n_weeks=self.every_n_weeks,
98        )
99        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', '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):
102class CompetitionSeriesInfo(CompetitionSeriesConfig):
103    """A series as the organiser's page reads it."""
104
105    id: str = ""
106    created_at: AwareDatetime | None = None
107    created_by: str = ""
108    #: The next scheduled start, or ``None`` for a series whose schedule
109    #: is over or unusable. Derived, never stored.
110    next_occurrence_at: AwareDatetime | None = None
111    #: How many occurrences already exist. A count is the right shape
112    #: *here* — this is a read for a human, not the job's idempotency,
113    #: which asks the database which instants exist.
114    occurrences_spawned: int = 0
115    #: ⚠️ Whether the job that actually spawns is switched on. It ships
116    #: enabled, unlike `awd_tick`, but a super-admin can pause it from
117    #: `/admin/scheduled-jobs` — and a series page that cannot say so
118    #: leaves an organiser reading a perfectly configured schedule that
119    #: will never run. Absent means not seeded, which is the same thing
120    #: as off to a reader; reporting it as on is the false reassurance
121    #: this field exists to remove.
122    spawn_job_enabled: bool = False
123    #: How many of `pool` declare a patchable surface.
124    #:
125    #: ⚠️ Derived here rather than in each client, which is the opposite
126    #: of how the competition wizard answers the same question — and the
127    #: difference is not an inconsistency. That form has just *browsed*
128    #: the catalog, so it holds the rows; a series client holds ids and
129    #: nothing else, and there is no by-id read on `GET /challenges`. Two
130    #: clients deriving it would be two clients disagreeing about whether
131    #: a weekly can score defence at all.
132    #:
133    #: The reason it is worth a field: `scoring_rule` and `pool` are set
134    #: in one form and nothing cross-checks them, so an "attack &
135    #: defence" weekly over a pool with nothing patchable scores zero
136    #: defence **every week**, unattended, with no error anywhere. This
137    #: is a report, never a refusal — a pool an organiser intends to fill
138    #: later is legitimate.
139    defensible_pool_count: int = 0
140    #: Pool entries the catalog does not have. A separate and equally
141    #: silent failure: `plan_slate` refuses an occurrence whose slate it
142    #: cannot fill, so a pool naming a since-deleted challenge produces a
143    #: Saturday with no contest rather than an error.
144    unknown_pool_ids: list[str] = Field(default_factory=list)
145    #: Pool entries **another series** also draws from — the third member
146    #: of the same family, and the one with a security edge rather than a
147    #: scoring one.
148    #:
149    #: `plan_slate` consumes a pool *within one series*, so two series
150    #: overlapping is invisible from either: nothing refuses, nothing
151    #: logs, and both weeklies are individually correct. What it costs is
152    #: that a challenge played on the internal ladder is **pre-leaked**
153    #: when the public one draws it, and the first sign is a public
154    #: Saturday where some squad solves in ninety seconds.
155    #:
156    #: A report, never a refusal, exactly like the two above: an
157    #: organiser staging both series out of one draft pool, or
158    #: deliberately re-running a problem for a different audience, is
159    #: doing something legitimate.
160    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 CompetitionSessionEnd(ctfy.core.models.CtfyModel):
406class CompetitionSessionEnd(CtfyModel):
407    """Close the session that is running right now, at this instant.
408
409    ``confirm`` must be ``true``: ending a session stops every environment
410    within one teardown tick and there is no way back, so the verb refuses
411    an unconfirmed call instead of trusting the client to have asked.
412    """
413
414    confirm: bool = False

Close the session that is running right now, at this instant.

confirm must be true: ending a session stops every environment within one teardown tick and there is no way back, so the verb refuses an unconfirmed call instead of trusting the client to have asked.

confirm: bool = False
class CompetitionSessionExtend(ctfy.core.models.CtfyModel):
396class CompetitionSessionExtend(CtfyModel):
397    """Stretch the session that is running right now by ``minutes`` (#2128).
398
399    The bound is the route's to state (``MAX_EXTEND_MINUTES``), refused as
400    its own code rather than as a generic 422, so a typo reads as one.
401    """
402
403    minutes: int

Stretch the session that is running right now by minutes (#2128).

The bound is the route's to state (MAX_EXTEND_MINUTES), refused as its own code rather than as a generic 422, so a typo reads as one.

minutes: int = PydanticUndefined
class CompetitionSessionInfo(ctfy.core.models.CtfyModel):
389class CompetitionSessionInfo(CtfyModel):
390    """One playable slice of a competition's window, on the wire."""
391
392    starts_at: datetime
393    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 CompetitionTcpAccessInfo(ctfy.core.models.CtfyModel):
567class CompetitionTcpAccessInfo(CtfyModel):
568    """``GET /admin/competitions/{id}/tcp-access`` — the default and every TCP challenge."""
569
570    competition_id: str
571    tcp_access: TcpAccess
572    rows: list[ChallengeTcpAccessRow] = Field(default_factory=list)
573    #: Live nodes that declared a public port range. ``0`` with a
574    #: ``public_port`` choice anywhere here is a start that will be refused
575    #: (``tcp_public_port_unavailable``) — said on the page before a player
576    #: meets it.
577    public_port_nodes: int = 0
578    #: Live nodes that terminate TLS on their ``websecure`` :443. ``0`` with a
579    #: TCP challenge here reached by ``tls_sni`` is a start that will be
580    #: refused (``tcp_tls_sni_unavailable``).
581    tls_sni_nodes: int = 0

GET /admin/competitions/{id}/tcp-access — the default and every TCP challenge.

competition_id: str = PydanticUndefined
tcp_access: ctfy.core.enums.TcpAccess = PydanticUndefined
rows: list[ChallengeTcpAccessRow] = PydanticUndefined
public_port_nodes: int = 0
tls_sni_nodes: int = 0
class CompetitionTopicCreate(ctfy.core.models.CtfyModel):
2621class CompetitionTopicCreate(CtfyModel):
2622    """An organiser's new topic. ``release_at`` omitted (or ``null``) takes
2623    the start of the next authoring window no other topic is released at
2624    (``core.competition_topics.default_release_at``); with no such window
2625    the request is refused ``topic_release_at_required``."""
2626
2627    title: LocalizedText = Field(default_factory=dict)
2628    body: LocalizedText = Field(default_factory=dict)
2629    release_at: datetime | None = None
2630
2631    _v_title = field_validator("title", mode="before")(
2632        lambda v: localized_field(v, cap=MAX_TOPIC_TITLE_CHARS, field="title")
2633    )
2634    _v_body = field_validator("body", mode="before")(
2635        lambda v: localized_field(v, cap=MAX_HANDBOOK_CHARS, field="body")
2636    )
2637    _norm_times = field_validator("release_at", mode="before")(_blank_to_none)

An organiser's new topic. release_at omitted (or null) takes the start of the next authoring window no other topic is released at (core.competition_topics.default_release_at); with no such window the request is refused topic_release_at_required.

title: dict[str, str] = PydanticUndefined
body: dict[str, str] = PydanticUndefined
release_at: datetime.datetime | None = None
class CompetitionTopicInfo(ctfy.core.models.CtfyModel):
2587class CompetitionTopicInfo(CtfyModel):
2588    """One scheduled topic on a competition (#1965 T) — the day's brief.
2589
2590    An entrant only ever receives released topics; an organiser receives
2591    all of them, and ``released`` says which have reached their instant.
2592    ``release_at`` is the instant the clock releases it — no job does.
2593    """
2594
2595    id: str
2596    competition_id: str
2597    #: ``{locale: text}``; the reader picks its language.
2598    title: LocalizedText = Field(default_factory=dict)
2599    #: The topic, markdown, ``{locale: text}``.
2600    body: LocalizedText = Field(default_factory=dict)
2601    release_at: datetime
2602    #: Whether ``release_at`` had passed when this answer was composed.
2603    #: ⚠️ For an entrant it is always ``true`` — they are never sent an
2604    #: unreleased topic — so it is the organiser's column.
2605    released: bool
2606    created_at: datetime
2607    updated_at: datetime

One scheduled topic on a competition (#1965 T) — the day's brief.

An entrant only ever receives released topics; an organiser receives all of them, and released says which have reached their instant. release_at is the instant the clock releases it — no job does.

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
title: dict[str, str] = PydanticUndefined
body: dict[str, str] = PydanticUndefined
release_at: datetime.datetime = PydanticUndefined
released: bool = PydanticUndefined
created_at: datetime.datetime = PydanticUndefined
updated_at: datetime.datetime = PydanticUndefined
class CompetitionTopicUpdate(ctfy.core.models.CtfyModel):
2640class CompetitionTopicUpdate(CtfyModel):
2641    """An organiser's edit to one topic. Partial (``model_fields_set``): an
2642    omitted field is left alone. ``release_at`` may move either way — a
2643    topic moved into the past is released at once, one moved into the
2644    future is withdrawn from entrants until then."""
2645
2646    title: LocalizedText | None = None
2647    body: LocalizedText | None = None
2648    release_at: datetime | None = None
2649
2650    _v_title = field_validator("title", mode="before")(
2651        lambda v: localized_field(v, cap=MAX_TOPIC_TITLE_CHARS, field="title")
2652    )
2653    _v_body = field_validator("body", mode="before")(
2654        lambda v: localized_field(v, cap=MAX_HANDBOOK_CHARS, field="body")
2655    )
2656    _norm_times = field_validator("release_at", mode="before")(_blank_to_none)

An organiser's edit to one topic. Partial (model_fields_set): an omitted field is left alone. release_at may move either way — a topic moved into the past is released at once, one moved into the future is withdrawn from entrants until then.

title: dict[str, str] | None = None
body: dict[str, str] | None = None
release_at: datetime.datetime | None = None
class CompetitionTopicsResponse(ctfy.core.models.CtfyModel):
2610class CompetitionTopicsResponse(CtfyModel):
2611    """A competition's topics as this caller may read them, in release
2612    order. The whole set: there are at most
2613    ``core.competition_topics.MAX_COMPETITION_TOPICS``, so it is not paged
2614    and ``total`` is ``len(topics)``."""
2615
2616    competition_id: str
2617    topics: list[CompetitionTopicInfo] = Field(default_factory=list)
2618    total: int = 0

A competition's topics as this caller may read them, in release order. The whole set: there are at most core.competition_topics.MAX_COMPETITION_TOPICS, so it is not paged and total is len(topics).

competition_id: str = PydanticUndefined
topics: list[CompetitionTopicInfo] = PydanticUndefined
total: int = 0
class CompetitionUpdate(ctfy.core.models.CtfyModel):
1261class CompetitionUpdate(CtfyModel):
1262    """PATCH body — every field optional, ``None`` means "leave alone"."""
1263
1264    #: A bare title, as every caller sent before #2230. ⚠️ **Merged, not
1265    #: replaced**: it sets the locale ``from_untagged_title`` files it under
1266    #: and keeps the other, so an older client renaming an event does not
1267    #: delete its English name. Ignored when :attr:`title_i18n` is given.
1268    title: str | None = Field(default=None, min_length=1, max_length=MAX_COMPETITION_TITLE_CHARS)
1269    #: The whole title map — **replaced**, like ``handbook``, so a client
1270    #: clears one translation by leaving it out. It must keep at least one
1271    #: locale: a competition always has a name.
1272    title_i18n: LocalizedText | None = None
1273
1274    _v_title_i18n = field_validator("title_i18n", mode="before")(title_map_field)
1275
1276    @field_validator("title_i18n", mode="after")
1277    @classmethod
1278    def _title_not_emptied(cls, v: LocalizedText | None) -> LocalizedText | None:
1279        if v is not None and not v:
1280            raise ValueError("title_i18n: keep the title in at least one of en / zh-CN")
1281        return v
1282
1283    description: LocalizedText | None = None
1284    #: ``None`` leaves the handbook alone; ``{}`` clears it. The route
1285    #: stamps ``handbook_updated_at`` only when the text really differs,
1286    #: so re-saving the form does not move that date.
1287    #:
1288    #: ⚠️ **The map is replaced, never merged.** Every other field on
1289    #: this endpoint replaces, and a merge would make clearing one
1290    #: locale impossible — a client that sends only ``zh-CN`` means
1291    #: "these are the translations now". The CLI reads the stored map
1292    #: and sends the merged result, which is where a per-locale flag
1293    #: belongs.
1294    handbook: LocalizedText | None = None
1295
1296    _v_description = field_validator("description", mode="before")(
1297        lambda v: localized_field(v, cap=MAX_DESCRIPTION_CHARS, field="description")
1298    )
1299    _v_handbook = field_validator("handbook", mode="before")(
1300        lambda v: localized_field(v, cap=MAX_HANDBOOK_CHARS, field="handbook")
1301    )
1302    starts_at: datetime | None = None
1303    ends_at: datetime | None = None
1304    challenge_ids: list[str] | None = Field(default=None)  # no cap — see CompetitionCreate
1305    #: ``None`` leaves it alone (#2553). ⚠️ Never moved by an edit of the
1306    #: set: emptying ``challenge_ids`` under ``set`` leaves nothing to play.
1307    challenge_scope: ChallengeScope | None = None
1308    status: CompetitionStatus | None = None
1309    access: CompetitionAccess | None = None
1310    # ``None`` = untouched, like every field here (#2097).
1311    kind: CompetitionKind | None = None
1312    eval_standard: bool | None = None
1313    corpus_sha: str | None = None
1314    source_name: str | None = Field(default=None, max_length=200)
1315    source_url: WebUrl | None = Field(default=None, max_length=2048)
1316    license: str | None = Field(default=None, max_length=200)
1317    maintainer: str | None = Field(default=None, max_length=200)
1318    version_date: str | None = Field(default=None, max_length=10)
1319
1320    @field_validator("version_date")
1321    @classmethod
1322    def _version_date(cls, value: str | None) -> str | None:
1323        if value and date.fromisoformat(value).isoformat() != value:
1324            raise ValueError("version_date must be YYYY-MM-DD")
1325        return value
1326
1327    # ⚠️ ``None`` means untouched, like every field here. A handler
1328    # reading truthiness instead could only ever turn this *on*, so an
1329    # organiser's opt-out would be undone by the next unrelated save.
1330    agents_allowed: bool | None = None
1331    # ⚠️ ``None`` means untouched, for the reason directly above.
1332    authoring_enabled: bool | None = None
1333    # ⚠️ ``None`` means untouched, for the reason directly above: the
1334    # edit form re-sends every field, so a plain ``False`` default would
1335    # turn every unrelated save into a wall panel being switched off.
1336    agent_pulse: bool | None = None
1337    # ⚠️ ``None`` means untouched, for the reason directly above: the
1338    # edit form re-sends every field, so a plain ``False`` default would
1339    # re-open human registration on every unrelated save of an
1340    # exhibition. See CompetitionState.entrants_are_agents.
1341    entrants_are_agents: bool | None = None
1342    #: The periods uploads are taken in. ``None`` leaves them alone; ``[]``
1343    #: clears every window — open for as long as the event runs. Replaced
1344    #: whole, never merged, ``sessions``' rule. ⚠️ This is also what the
1345    #: old pair could not do: a deadline once set could not be cleared
1346    #: through PATCH, because ``""`` normalised to "leave alone".
1347    authoring_windows: list[AuthoringWindowInfo] | None = None
1348    # ⚠️ ``None`` means untouched, like every field here, and ``0`` is a
1349    # real value on both: the way back to the platform's per-team cap,
1350    # and to no capacity. See CompetitionState.authoring_pending_per_team
1351    # / authoring_queue_capacity.
1352    authoring_pending_per_team: int | None = Field(default=None, ge=0)
1353    authoring_queue_capacity: int | None = Field(default=None, ge=0)
1354    # ``0`` is a real value: the way back to the platform's rebuild quota.
1355    # See CompetitionState.max_rebuilds_per_challenge.
1356    max_rebuilds_per_challenge: int | None = Field(default=None, ge=0, le=50)
1357    # ``0`` is a real value: the way back to the platform's instance cap.
1358    # See CompetitionState.max_instances_per_team.
1359    max_instances_per_team: int | None = Field(default=None, ge=0, le=10_000)
1360    # ``0`` is a real value: the limit off. See
1361    # CompetitionState.authoring_rate_limit_count.
1362    authoring_rate_limit_count: int | None = Field(default=None, ge=0, le=10_000)
1363    authoring_rate_limit_window_s: int | None = Field(default=None, ge=0, le=31_536_000)
1364    # ``0`` is a real value: unlimited. See
1365    # CompetitionState.authoring_uploads_per_window.
1366    authoring_uploads_per_window: int | None = Field(default=None, ge=0, le=10_000)
1367    #: Pin (``True``) or unpin (``False``) the event for everybody (#2070).
1368    #: ⚠️ **Platform admins only**: this endpoint is also the organiser's, and
1369    #: a pin reorders the list for *every* event's audience, so a per-event
1370    #: organiser sending it is refused ``pin_requires_platform_admin`` rather
1371    #: than silently ignored. ``None`` leaves it alone.
1372    pinned: bool | None = None
1373    # See CompetitionState.report_disclosure. Not audited the way a live
1374    # scoring change is: it changes what players are told from here on and
1375    # re-ranks nothing.
1376    report_disclosure: ReportDisclosure | None = None
1377    # See CompetitionState.author_team_detail. Not audited like a scoring
1378    # change: it changes what an author is shown from now on.
1379    author_team_detail: AuthorTeamDetail | None = None
1380    # #2041: not frozen — it decides the next version switch and the next
1381    # change to the set, never one already made.
1382    lock_challenge_versions: bool | None = None
1383    # See CompetitionState.pre_start_challenge_count_public.
1384    pre_start_challenge_count_public: bool | None = None
1385    #: ``None`` leaves it alone. See CompetitionState.flag_prefix. Changeable
1386    #: only before the start (``core.competitions.flag_prefix_locked``):
1387    #: after that a *change* is ``409 flag_prefix_locked``, since flags
1388    #: already minted keep the old word; re-sending the stored value saves.
1389    #: Out of shape is ``422 flag_prefix_invalid``.
1390    flag_prefix: str | None = Field(default=None, max_length=64)
1391    #: The competition whose challenge set this one follows (#2234). ``None``
1392    #: leaves the link alone, ``""`` stops following — the set the event
1393    #: holds at that moment becomes its own curated list (or the
1394    #: ``challenge_ids`` the same request sends). While a link is set the
1395    #: event's own list is not editable: ``challenge_ids`` is accepted only
1396    #: when it re-sends the set the event already has.
1397    challenge_source_competition_id: str | None = Field(default=None, max_length=200)
1398    # #2041: adding a challenge to, or taking one out of, an event that
1399    # locks its challenges while it is in play needs the organiser to
1400    # confirm and say why; the reason is recorded per challenge. Not a
1401    # column, like ``scoring_change_reason``.
1402    challenge_change_confirmed: bool | None = None
1403    challenge_change_reason: str | None = Field(default=None, max_length=2000)
1404    # ``""`` is a real value on both — how an organiser stops asking for
1405    # an agreement — and the pair is checked against the state the patch
1406    # *leaves*, so clearing one alone is refused.
1407    authoring_terms_version: str | None = Field(default=None, max_length=64)
1408    authoring_terms_url: WebUrl | None = Field(default=None, max_length=500)
1409    # ``None`` is untouched; ``""`` is a real value: the way back to *not
1410    # set*. See CompetitionState.organiser_contact_email.
1411    organiser_contact_email: str | None = Field(default=None, max_length=254)
1412    # ⚠️ ``None`` means untouched, like every field here. See
1413    # CompetitionState.authoring_review.
1414    authoring_review: AuthoringReview | None = None
1415    # ⚠️ ``None`` means untouched. Switching it on with no live labelled
1416    # node refuses the event's next start (``dedicated_pool_unavailable``)
1417    # rather than placing it on the shared pools. See
1418    # CompetitionState.dedicated_pool.
1419    dedicated_pool: bool | None = None
1420    # ⚠️ ``None`` means untouched. Takes effect from the next upload; runs
1421    # already spent in the open window still count. See
1422    # CompetitionState.authoring_smoke_quota.
1423    authoring_smoke_quota: int | None = Field(default=None, ge=1, le=1000)
1424    # ⚠️ ``None`` means untouched. Takes effect from the next publish; an
1425    # upload already published stays where it is. See
1426    # CompetitionState.authoring_public_on_publish.
1427    authoring_public_on_publish: bool | None = None
1428    # ⚠️ ``None`` means untouched. Takes effect from the next upload; a run
1429    # already queued still runs, and a reviewer's own smoke is never asked.
1430    # See CompetitionState.authoring_auto_smoke.
1431    authoring_auto_smoke: bool | None = None
1432    # ⚠️ ``None`` means untouched. Takes effect at the next read: a review
1433    # already taken is shown (or withheld) by the switch as it is now. See
1434    # CompetitionState.authoring_authors_read_model_review.
1435    authoring_authors_read_model_review: bool | None = None
1436    # ⚠️ ``None`` means untouched; ``[]`` turns the AI pre-check off (#3053).
1437    # Each id must name an active gateway model. Takes effect at the next
1438    # version's smoke. See CompetitionState.authoring_probe_models.
1439    authoring_probe_models: list[str] | None = Field(default=None, max_length=16)
1440    # ⚠️ ``None`` means untouched; ``""`` follows the first black-box model.
1441    authoring_probe_whitebox_model: str | None = Field(default=None, max_length=200)
1442    # ⚠️ ``None`` means untouched (#3189). Blind runs per model on each
1443    # version, 1..3; takes effect at the next version's pre-check. See
1444    # CompetitionState.authoring_probe_blind_runs.
1445    authoring_probe_blind_runs: int | None = Field(default=None, ge=1, le=MAX_PROBE_BLIND_RUNS)
1446    # ⚠️ ``None`` means untouched, each of the four (#1965 C3). Takes
1447    # effect at the next upload; the rejections already recorded are read
1448    # under the new rule. See CompetitionState.authoring_cooldown.
1449    authoring_cooldown: AuthoringCooldown | None = None
1450    authoring_cooldown_threshold: int | None = Field(default=None, ge=1, le=1000)
1451    authoring_cooldown_window_s: int | None = Field(default=None, ge=60, le=365 * 24 * 3600)
1452    authoring_cooldown_pause_s: int | None = Field(default=None, ge=60, le=365 * 24 * 3600)
1453    # #2885: the event's own value for each overridable platform authoring
1454    # setting. ⚠️ ``None`` means untouched; otherwise **only the keys the
1455    # request carries** are applied (``authoring_overrides.merge``), and
1456    # ``0`` is the way back to the platform's value — never unlimited, never
1457    # off. Changing a node-spending key (``PLATFORM_ADMIN_KEYS``) needs a
1458    # platform admin: ``403 authoring_override_requires_platform_admin``.
1459    # Not frozen: the next start, build or check reads it.
1460    authoring_overrides: AuthoringSettings | None = None
1461    # Rejected with 409 once the competition is live — re-submitting the
1462    # value it already holds is a no-op, so the edit form can keep
1463    # sending every field when the admin saves any of them.
1464    #
1465    # ⚠️ Typed like ``CompetitionCreate``'s, and the reason is that they
1466    # are two doors onto one field. Narrowing only create left an unknown
1467    # rule refused by pydantic there and by ``_check_scoring``'s
1468    # ``build_rule`` here — same 422, two different bodies, for the same
1469    # mistake. Three states, all real: ``None`` leaves it alone, ``""``
1470    # selects the platform default, a member names a rule.
1471    scoring_rule: ScoringRuleName | Literal[""] | None = None
1472    scoring_params: dict[str, Any] | None = None
1473    #: ``None`` leaves it alone. Changeable only before the competition is
1474    #: live (``scoring_live``): after that it is ``409
1475    #: scoring_rule_version_locked`` — a version decides what every capture
1476    #: already on the board is worth, so it is never a live re-rank (#2731).
1477    scoring_rule_version: ScoringRuleVersion | None = None
1478    #: Why the scoring is changing — **required** when the competition is
1479    #: live (``scoring_live``) and the rule or its params actually move
1480    #: (#1965 H, decision 10): the change is then recorded with it, shown on
1481    #: the board and readable at ``GET /competitions/{id}/scoring-changes``.
1482    #: Absent or blank there is ``422 scoring_change_needs_reason``; ignored when
1483    #: nothing about the scoring moves or the event is not live yet.
1484    scoring_change_reason: str | None = Field(default=None, max_length=2000)
1485    # ⚠️ Optional like every field here: ``None`` means untouched.
1486    # Flipping silence mid-event is a real organiser action (a range run
1487    # loud for a practice round, then silent for the graded one), so it
1488    # needs none of the audit a mid-event ``scoring_rule`` change does —
1489    # silence changes what the player is told from here on, it does not
1490    # re-rank a board they have already seen.
1491    silent: bool | None = None
1492    #: ``None`` leaves the schedule alone; ``[]`` clears every session and
1493    #: returns the competition to a single continuous window.
1494    sessions: list[CompetitionSessionInfo] | None = None
1495    # ⚠️ Optional like every field here: ``None`` means untouched. Not
1496    # audited the way a live ``scoring_rule`` change is — extending the
1497    # report period on the day is ordinary event work, and it re-ranks
1498    # nothing.
1499    report_window_s: int | None = None
1500    # ⚠️ ``None`` means untouched, like every field here. **Widening is
1501    # free; narrowing is refused once anybody has relied on the old
1502    # setting** (``anonymity_promised``, 409) — an organiser flipping
1503    # ``optional`` back to ``forbidden`` would publish every entrant who
1504    # asked to be withheld, in one click, permanently, with nothing on
1505    # the page to say so. The *kind* is free in both directions: it
1506    # decides whether a withheld row is renamed or removed, and neither
1507    # answer publishes a name.
1508    anonymous_entry: AnonymityPolicy | None = None
1509    anonymous_entry_kind: AnonymityKind | None = None
1510    # ⚠️ ``None`` means untouched, like every field here, and neither is
1511    # frozen: tightening the internet mid-event is ordinary organiser work,
1512    # and it changes the *next* start, never a running box. The override
1513    # map is **replaced**, never merged (``handbook``'s rule) — ``{}``
1514    # clears every override.
1515    internet_policy: InternetPolicy | None = None
1516    challenge_internet: dict[str, InternetAccess] | None = None
1517    #: #2356: ``None`` = untouched; not frozen — it changes the next run's
1518    #: network, never a running one.
1519    agent_internet: InternetAccess | None = None
1520    # #2082: ``None`` = untouched, and neither is frozen — a new way in
1521    # applies to the *next* start, never a running box. The override map is
1522    # **replaced**, never merged; ``{}`` clears every override.
1523    tcp_access: TcpAccess | None = None
1524    challenge_tcp_access: dict[str, TcpAccess] | None = None
1525    # ``None`` = untouched. Not frozen: read at the next boundary / solve.
1526    session_end_mode: SessionEndMode | None = None
1527    session_end_grace_s: int | None = Field(default=None, ge=0, le=86_400)
1528    solve_env_mode: SolveEnvMode | None = None
1529    solve_grace_s: int | None = Field(default=None, ge=0, le=86_400)
1530    # ``None`` = untouched. Not frozen (#2183): read at the next submission.
1531    after_close: AfterClose | None = None
1532    practice_instance_cap: int | None = Field(default=None, ge=0, le=10_000)
1533    practice_ttl_s: int | None = Field(default=None, ge=60, le=7 * 86_400)
1534    # ``None`` = untouched. Not frozen: moving the freeze instant mid-event is
1535    # an organiser's decision, and ``0`` switches it off (#2133).
1536    freeze_minutes: int | None = Field(default=None, ge=0, le=10_080)
1537    freeze_auto_unfreeze: bool | None = None
1538    # ``None`` = untouched. Not frozen: a view already made keeps the terms
1539    # it was made under (``HintViewState``), so switching mid-event prices
1540    # only the next one (#2101).
1541    hint_cost_mode: HintCostMode | None = None
1542    # --- registration ---------------------------------------------------
1543    # The window sign-ups are open, distinct from the play window above.
1544    # ``CompetitionState.registration_starts_at`` is the derivation.
1545    registration_starts_at: datetime | None = None
1546    registration_ends_at: datetime | None = None
1547    # Per-competition roster cap. 0 defers to the platform-wide setting,
1548    # so two events on one deployment can carry different rules.
1549    max_team_size: int | None = Field(default=None, ge=0)
1550    # When true a newly registered team lands ``pending``. A marker only,
1551    # unless ``play_requires_approval`` is also on.
1552    registration_review: bool | None = None
1553    #: Only an ``approved`` team may play or rank (#2223). ``None`` leaves it.
1554    play_requires_approval: bool | None = None
1555    #: ``None`` leaves it (#2238).
1556    team_rename_locked_after_start: bool | None = None
1557    # ``None`` leaves the set alone; ``[]`` opens the competition to everyone
1558    # (#2184) -- the way back, which is why it is sendable.
1559    eligible_domains: list[str] | None = None
1560    eligibility_mode: Literal["domains", "mainland_student"] | None = None
1561    eligibility_carsi_roles: list[str] | None = None
1562    team_entity_policy: Literal["none", "same_institution"] | None = None
1563    #: ``None`` leaves it (#2293).
1564    eligibility_students_only: bool | None = None
1565    #: ``None`` leaves it (#2767); ``0`` removes the floor.
1566    min_team_size: int | None = Field(default=None, ge=0)
1567    on_ineligible: Literal["refuse", "review"] | None = None
1568    eligibility_exempt_invited: bool | None = None
1569    #: ``None`` leaves it (#2932).
1570    eligibility_email_satisfies_role: bool | None = None
1571    registration_capacity: int | None = Field(default=None, ge=0, le=100000)
1572    capacity_counts: CapacityCounts | None = None
1573    at_capacity: AtCapacity | None = None
1574    # field name -> policy; anything absent is treated as hidden.
1575    # ``CompetitionState.registration_fields`` is the derivation. ⚠️ The
1576    # ``None`` here is this model's partial-update sentinel — *leave the
1577    # map alone* — and is not a third policy value.
1578    registration_fields: dict[str, RegistrationFieldPolicy] | None = None
1579    #: ``None`` leaves it alone, same as every other field here.
1580    pii_retention_days: int | None = Field(default=None, ge=0)
1581
1582    _norm_times = field_validator(
1583        "starts_at",
1584        "ends_at",
1585        "registration_starts_at",
1586        "registration_ends_at",
1587        mode="before",
1588    )(_blank_to_none)
1589
1590    @field_validator("registration_fields")
1591    @classmethod
1592    def _known_registration_fields(
1593        cls, v: dict[str, RegistrationFieldPolicy] | None
1594    ) -> dict[str, RegistrationFieldPolicy] | None:
1595        return None if v is None else _check_registration_fields(v)

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

title: str | None = None
title_i18n: dict[str, 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
challenge_scope: Optional[Literal['set', 'catalog']] = None
status: Optional[Literal['draft', 'published', 'archived']] = None
access: Optional[Literal['public', 'private_listed', 'private_hidden']] = None
kind: ctfy.core.enums.CompetitionKind | None = None
eval_standard: bool | None = None
corpus_sha: str | None = None
source_name: str | None = None
source_url: Optional[Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x74f512354e00>)]] = None
license: str | None = None
maintainer: str | None = None
version_date: 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_windows: list[AuthoringWindowInfo] | None = None
authoring_pending_per_team: int | None = None
authoring_queue_capacity: int | None = None
max_rebuilds_per_challenge: int | None = None
max_instances_per_team: int | None = None
authoring_rate_limit_count: int | None = None
authoring_rate_limit_window_s: int | None = None
authoring_uploads_per_window: int | None = None
pinned: bool | None = None
report_disclosure: Optional[Literal['default', 'after_lock', 'own', 'own_and_board']] = None
author_team_detail: Optional[Literal['off', 'after_end', 'always']] = None
lock_challenge_versions: bool | None = None
pre_start_challenge_count_public: bool | None = None
flag_prefix: str | None = None
challenge_source_competition_id: str | None = None
challenge_change_confirmed: bool | None = None
challenge_change_reason: str | None = None
authoring_terms_version: str | None = None
authoring_terms_url: Optional[Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x74f512354e00>)]] = None
organiser_contact_email: str | None = None
authoring_review: Optional[Literal['human', 'automatic']] = None
dedicated_pool: bool | None = None
authoring_smoke_quota: int | None = None
authoring_public_on_publish: bool | None = None
authoring_auto_smoke: bool | None = None
authoring_authors_read_model_review: bool | None = None
authoring_probe_models: list[str] | None = None
authoring_probe_whitebox_model: str | None = None
authoring_probe_blind_runs: int | None = None
authoring_cooldown: Optional[Literal['default', 'platform', 'competition', 'off']] = None
authoring_cooldown_threshold: int | None = None
authoring_cooldown_window_s: int | None = None
authoring_cooldown_pause_s: int | None = None
authoring_overrides: ctfy.core.authoring_overrides.AuthoringSettings | None = None
scoring_rule: Union[Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring', 'authoring'], Literal[''], NoneType] = None
scoring_params: dict[str, typing.Any] | None = None
scoring_rule_version: Optional[Literal['legacy', 'challenge_total']] = None
scoring_change_reason: str | 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
internet_policy: ctfy.core.enums.InternetPolicy | None = None
challenge_internet: dict[str, ctfy.core.enums.InternetAccess] | None = None
agent_internet: ctfy.core.enums.InternetAccess | None = None
tcp_access: ctfy.core.enums.TcpAccess | None = None
challenge_tcp_access: dict[str, ctfy.core.enums.TcpAccess] | None = None
session_end_mode: ctfy.core.enums.SessionEndMode | None = None
session_end_grace_s: int | None = None
solve_env_mode: ctfy.core.enums.SolveEnvMode | None = None
solve_grace_s: int | None = None
after_close: Optional[Literal['stop', 'practice']] = None
practice_instance_cap: int | None = None
practice_ttl_s: int | None = None
freeze_minutes: int | None = None
freeze_auto_unfreeze: bool | None = None
hint_cost_mode: Optional[Literal['discount', 'fixed', 'record']] = 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
play_requires_approval: bool | None = None
team_rename_locked_after_start: bool | None = None
eligible_domains: list[str] | None = None
eligibility_mode: Optional[Literal['domains', 'mainland_student']] = None
eligibility_carsi_roles: list[str] | None = None
team_entity_policy: Optional[Literal['none', 'same_institution']] = None
eligibility_students_only: bool | None = None
min_team_size: int | None = None
on_ineligible: Optional[Literal['refuse', 'review']] = None
eligibility_exempt_invited: bool | None = None
eligibility_email_satisfies_role: bool | None = None
registration_capacity: int | None = None
capacity_counts: Optional[Literal['submitted', 'approved']] = None
at_capacity: Optional[Literal['stop', 'waitlist']] = None
registration_fields: dict[str, typing.Literal['hidden', 'optional', 'required']] | None = None
pii_retention_days: int | None = None
class ComposeRequest(ctfy.core.models.CtfyModel):
192class ComposeRequest(CtfyModel):
193    """An organiser's send. ``team_ids`` for ``team`` (one) and ``teams``
194    (several); ``user_id`` for ``user``."""
195
196    audience: ComposeAudience
197    user_id: str = ""
198    team_ids: list[str] = Field(default_factory=list, max_length=1000)
199    #: For ``teams``: conditions resolved by the server when the send happens,
200    #: and the union with ``team_ids`` (a team in both counts once).
201    conditions: list[TeamCondition] = Field(default_factory=list, max_length=8)
202    subject: str = Field(max_length=MAX_SUBJECT_CHARS * 2)
203    body: str = Field(max_length=MAX_ORGANISER_CHARS * 2)
204    #: Also send a mail reminder (title and link only, never the body).
205    email: bool = False
206    #: ``False`` makes a one-way notice: no reply is offered.
207    allow_reply: bool = True

An organiser's send. team_ids for team (one) and teams (several); user_id for user.

audience: Literal['user', 'team', 'teams', 'all_teams'] = PydanticUndefined
user_id: str = ''
team_ids: list[str] = PydanticUndefined
conditions: list[typing.Literal['all', 'report_missing', 'review_not_approved', 'no_solves']] = PydanticUndefined
subject: str = PydanticUndefined
body: str = PydanticUndefined
email: bool = False
allow_reply: bool = True
class ComposeResult(ctfy.core.models.CtfyModel):
241class ComposeResult(CtfyModel):
242    """What a send did."""
243
244    broadcast_id: str = ""
245    thread_ids: list[str] = Field(default_factory=list)
246    threads: int = 0
247    recipients: int = 0
248    #: Reminder mails queued (recipients with an address who accept the
249    #: ``messages`` category; the dispatcher asks again at send time).
250    emails_queued: int = 0

What a send did.

broadcast_id: str = ''
thread_ids: list[str] = PydanticUndefined
threads: int = 0
recipients: int = 0
emails_queued: int = 0
class ConditionCellInfo(ctfy.core.models.CtfyModel):
434class ConditionCellInfo(CtfyModel):
435    """One member x condition cell of the reviewer's matrix (#2767)."""
436
437    condition: ConditionId
438    status: CellStatus
439    #: Why it is not met; ``""`` when it is.
440    reason: ConditionReason = ""

One member x condition cell of the reviewer's matrix (#2767).

condition: Literal['proof', 'role', 'institution', 'same_institution', 'team_size', 'registration_complete'] = PydanticUndefined
status: Literal['met', 'unmet', 'unknown', 'exempt'] = PydanticUndefined
reason: Literal['', 'proof_missing', 'proof_unverified', 'role_not_accepted', 'role_unknown', 'institution_not_listed', 'institution_mixed', 'team_too_small', 'team_too_large', 'registration_missing', 'registration_incomplete', 'blocked_by_proof', 'blocked_by_institution', 'blocked_by_role'] = ''
class ConditionMemberRow(ctfy.core.models.CtfyModel):
443class ConditionMemberRow(CtfyModel):
444    """One member's row of the matrix."""
445
446    user_id: str
447    #: Why an organiser's exemption applies, ``""`` = none (decision 4).
448    exempt: ExemptReason = ""
449    cells: list[ConditionCellInfo] = Field(default_factory=list)
450    #: The required fields this member left blank (field keys, never values).
451    missing_fields: list[str] = Field(default_factory=list)

One member's row of the matrix.

user_id: str = PydanticUndefined
exempt: Literal['', 'organiser', 'invited'] = ''
cells: list[ConditionCellInfo] = PydanticUndefined
missing_fields: list[str] = PydanticUndefined
class ConditionTeamResult(ctfy.core.models.CtfyModel):
454class ConditionTeamResult(CtfyModel):
455    """A team-level block's answer (team size, same institution)."""
456
457    condition: ConditionId
458    status: ConditionStatus
459    reason: ConditionReason = ""

A team-level block's answer (team size, same institution).

condition: Literal['proof', 'role', 'institution', 'same_institution', 'team_size', 'registration_complete'] = PydanticUndefined
status: Literal['met', 'unmet', 'unknown'] = PydanticUndefined
reason: Literal['', 'proof_missing', 'proof_unverified', 'role_not_accepted', 'role_unknown', 'institution_not_listed', 'institution_mixed', 'team_too_small', 'team_too_large', 'registration_missing', 'registration_incomplete', 'blocked_by_proof', 'blocked_by_institution', 'blocked_by_role'] = ''
class ConditionUnmetItem(ctfy.core.models.CtfyModel):
462class ConditionUnmetItem(CtfyModel):
463    """One item between the team and ``fully_eligible``; ``user_id`` ``""`` = the team."""
464
465    condition: ConditionId
466    status: ConditionStatus
467    reason: ConditionReason
468    user_id: str = ""

One item between the team and fully_eligible; user_id "" = the team.

condition: Literal['proof', 'role', 'institution', 'same_institution', 'team_size', 'registration_complete'] = PydanticUndefined
status: Literal['met', 'unmet', 'unknown'] = PydanticUndefined
reason: Literal['', 'proof_missing', 'proof_unverified', 'role_not_accepted', 'role_unknown', 'institution_not_listed', 'institution_mixed', 'team_too_small', 'team_too_large', 'registration_missing', 'registration_incomplete', 'blocked_by_proof', 'blocked_by_institution', 'blocked_by_role'] = PydanticUndefined
user_id: str = ''
class ConditionsPreviewItem(ctfy.core.models.CtfyModel):
530class ConditionsPreviewItem(CtfyModel):
531    """One unmet item of a previewed team, with the member it is about named."""
532
533    condition: ConditionId
534    status: ConditionStatus
535    reason: ConditionReason
536    #: ``""`` = the team (size, same institution).
537    user_id: str = ""
538    #: The member's display name, never a dossier field; ``""`` for the team.
539    display_name: str = ""

One unmet item of a previewed team, with the member it is about named.

condition: Literal['proof', 'role', 'institution', 'same_institution', 'team_size', 'registration_complete'] = PydanticUndefined
status: Literal['met', 'unmet', 'unknown'] = PydanticUndefined
reason: Literal['', 'proof_missing', 'proof_unverified', 'role_not_accepted', 'role_unknown', 'institution_not_listed', 'institution_mixed', 'team_too_small', 'team_too_large', 'registration_missing', 'registration_incomplete', 'blocked_by_proof', 'blocked_by_institution', 'blocked_by_role'] = PydanticUndefined
user_id: str = ''
display_name: str = ''
class ConditionsPreviewTeam(ctfy.core.models.CtfyModel):
542class ConditionsPreviewTeam(CtfyModel):
543    """One registered team the proposed conditions would flag (#2767)."""
544
545    team_id: str
546    team_name: str = ""
547    state: TeamVerdictState
548    reason: ConditionReason = ""
549    unmet: list[ConditionsPreviewItem] = Field(default_factory=list)

One registered team the proposed conditions would flag (#2767).

team_id: str = PydanticUndefined
team_name: str = ''
state: Literal['fully_eligible', 'missing_n', 'ineligible'] = PydanticUndefined
reason: Literal['', 'proof_missing', 'proof_unverified', 'role_not_accepted', 'role_unknown', 'institution_not_listed', 'institution_mixed', 'team_too_small', 'team_too_large', 'registration_missing', 'registration_incomplete', 'blocked_by_proof', 'blocked_by_institution', 'blocked_by_role'] = ''
unmet: list[ConditionsPreviewItem] = PydanticUndefined
class ContactOrganisersRequest(ctfy.core.models.CtfyModel):
185class ContactOrganisersRequest(CtfyModel):
186    """A player opening a thread with their event's organisers."""
187
188    subject: str = Field(max_length=MAX_SUBJECT_CHARS * 2)
189    body: str = Field(max_length=MAX_REPLY_CHARS * 2)

A player opening a thread with their event's organisers.

subject: str = PydanticUndefined
body: str = PydanticUndefined
class ContributorRow(ctfy.core.models.CtfyModel):
2838class ContributorRow(CtfyModel):
2839    """One author named on at least one published upload (plan P2.5).
2840
2841    ⚠️ **No points for uploading, so no ranking either.** The numbers are
2842    what players did with the challenges — the reception — and the list
2843    is ordered by name: a roll of who wrote the catalog, never a board
2844    the review queue would then be gamed for.
2845    """
2846
2847    user_id: str
2848    author_display_name: str
2849    #: Published uploads naming this person that this viewer may read.
2850    challenges: int
2851    #: Finishes summed over those challenges. ⚠️ ``None`` when any one of
2852    #: them has its tallies withheld: a partial sum is a claim about the
2853    #: whole, the absent-split-is-not-a-zero-split trap.
2854    solves: int | None = None
2855    #: Each reaction summed over the same challenges, largest first —
2856    #: per kind and never one total, since a report of a defect is not
2857    #: praise. ``None`` when withheld, for either of the row's reasons.
2858    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 CreateAdminInviteRequest(ctfy.core.models.CtfyModel):
24class CreateAdminInviteRequest(CtfyModel):
25    """Body for ``POST /admin/admin-invites``.
26
27    ``competition_id`` must be empty for ``platform_admin`` and set for
28    the two competition-scoped kinds. ``role`` matters only for
29    ``competition_admin`` (``admin`` or the narrower ``reviewer``).
30    """
31
32    email: str = Field(min_length=3, max_length=320)
33    kind: AdminInviteKind
34    competition_id: str = ""
35    role: CompetitionGrantRole = "admin"
36    expires_in_days: int = Field(default=DEFAULT_ADMIN_INVITE_DAYS, ge=1, le=MAX_ADMIN_INVITE_DAYS)

Body for POST /admin/admin-invites.

competition_id must be empty for platform_admin and set for the two competition-scoped kinds. role matters only for competition_admin (admin or the narrower reviewer).

email: str = PydanticUndefined
kind: Literal['platform_admin', 'competition_admin', 'authoring_reviewer'] = PydanticUndefined
competition_id: str = ''
role: Literal['admin', 'reviewer'] = 'admin'
expires_in_days: int = 14
class CreateFineGrainedTokenRequest(ctfy.core.models.CtfyModel):
444class CreateFineGrainedTokenRequest(CtfyModel):
445    """Body for ``POST /auth/tokens`` (mints a fine-grained token).
446
447    Back-compat: when ``competition_access`` / ``permissions`` are
448    omitted the server mints the broad legacy profile (every
449    competition, full participate access) so existing
450    ``mint_token(label)`` callers — SDK, CLI, MCP — keep getting a
451    token equivalent to the old agent token.
452    """
453
454    label: str = Field(default="", max_length=64)
455    # Lifetime in days. ``None`` uses the server default (30); ``0`` means
456    # "never expires". The server caps this at ~5 years to reject absurd
457    # values.
458    expires_in_days: int | None = Field(default=None, ge=0, le=365 * 5)
459    #: Start from a named template (``GET /auth/tokens/templates``).
460    #:
461    #: ⚠️ **A starting point the explicit fields override, never a
462    #: fourth way to say the same thing.** ``competition_access`` /
463    #: ``permissions`` / ``expires_in_days`` win where the caller sent
464    #: them, so a client can take ``agent_play`` and narrow it to two
465    #: competitions in one request — which is the flow the wizard walks
466    #: and would otherwise need two.
467    #:
468    #: ⚠️ Typed, unlike ``permissions`` one field down, and for the
469    #: opposite reason: there is no clamp for a template name. An
470    #: unrecognised one has no safe reading — falling back to the broad
471    #: default would mint a *wider* token than was asked for — so the
472    #: refusal is the feature and pydantic is the cheapest place to
473    #: state it.
474    template: TokenTemplateName | None = None
475    # ``None`` → legacy-broad default. Otherwise: none | all | selected.
476    competition_access: str | None = None
477    competition_ids: list[str] = Field(default_factory=list)
478    # Category → a ``PermissionLevel``. ⚠️ **Typed ``str`` on purpose**,
479    # unlike every other axis in this file: this is a *request*, and
480    # ``clamp_permissions`` exists to lower whatever arrives rather than
481    # refuse it. Narrowing the annotation would turn a clamp into a 422
482    # and break the tolerance the clamp was written for. ``None`` →
483    # legacy-broad default.
484    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):
487class CreateFineGrainedTokenResponse(TokenInfo):
488    """Includes the plaintext ``token`` — returned once, never persisted."""
489
490    token: str

Includes the plaintext token — returned once, never persisted.

token: str = PydanticUndefined
class CreateInviteRequest(ctfy.core.models.CtfyModel):
1046class CreateInviteRequest(CtfyModel):
1047    """Body for ``POST /api/v1/nodes/invites``.
1048
1049    ``ttl_seconds`` controls how long the minted registration token stays
1050    valid — it cannot be renewed, only re-created.
1051    """
1052
1053    ttl_seconds: int = Field(default=3600, ge=60, le=7 * 24 * 3600)

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
class CreateInviteResponse(ctfy.core.models.CtfyModel):
1056class CreateInviteResponse(CtfyModel):
1057    """Plaintext registration token is returned **exactly once** here.
1058
1059    Subsequent ``GET /nodes/invites`` lookups only expose metadata; the
1060    plaintext is never persisted — only ``token_hash`` is stored.
1061    """
1062
1063    id: str
1064    registration_token: str
1065    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 CreateJoinCommandRequest(ctfy.core.models.CtfyModel):
1068class CreateJoinCommandRequest(CtfyModel):
1069    """Body for ``POST /api/v1/nodes/join-commands`` (#2115).
1070
1071    ``profile`` picks the label set (`ctfy.core.node_join.labels_for_profile`);
1072    ``competition_id`` goes with the ``competition`` profile and nothing else.
1073    """
1074
1075    profile: NodeProfile = NodeProfile.NORMAL
1076    competition_id: str = ""
1077    display_name: str = ""
1078    capacity: int = Field(default=0, ge=0, le=10_000)
1079    ttl_seconds: int = Field(default=3600, ge=60, le=7 * 24 * 3600)

Body for POST /api/v1/nodes/join-commands (#2115).

profile picks the label set (ctfy.core.node_join.labels_for_profile); competition_id goes with the competition profile and nothing else.

profile: ctfy.core.node_join.NodeProfile = <NodeProfile.NORMAL: 'normal'>
competition_id: str = ''
display_name: str = ''
capacity: int = 0
ttl_seconds: int = 3600
class CreateJoinCommandResponse(ctfy.core.models.CtfyModel):
1082class CreateJoinCommandResponse(CtfyModel):
1083    """The one line to paste on the new machine, shown **exactly once**.
1084
1085    ``join_code`` carries the invite plaintext; like ``registration_token``
1086    it is never stored and never listed again.
1087    """
1088
1089    invite_id: str
1090    join_code: str
1091    command: str
1092    labels: dict[str, str] = Field(default_factory=dict)
1093    image: str
1094    expires_at: datetime | None = None

The one line to paste on the new machine, shown exactly once.

join_code carries the invite plaintext; like registration_token it is never stored and never listed again.

invite_id: str = PydanticUndefined
join_code: str = PydanticUndefined
command: str = PydanticUndefined
labels: dict[str, str] = PydanticUndefined
image: str = PydanticUndefined
expires_at: datetime.datetime | None = None
class CreditedChallengeRow(ctfy.core.models.CtfyModel):
2807class CreditedChallengeRow(CtfyModel):
2808    """One published upload whose author asked to be named (plan P2.5).
2809
2810    Only an upload in the catalog, credited ``display_name``, and not
2811    withheld from this viewer by the anonymity of the event it came from
2812    (`ctfy.server.anonymity.anonymise_author_credits`) is a row at all.
2813    """
2814
2815    id: str
2816    name: str
2817    category: ChallengeCategory = ChallengeCategory.WEB
2818    difficulty: str = ""
2819    author_user_id: str
2820    author_display_name: str
2821    #: Entrants (a team in one competition) that finished every question,
2822    #: across every event the challenge is played in — never under one
2823    #: scoring rule. ⚠️ ``None`` while a live silent exercise curating it
2824    #: withholds its tallies, **emptied, never zeroed**: a ``0`` there
2825    #: would be a claim that nobody has finished it.
2826    solves: int | None = None
2827    #: When the first entrant finished it; ``None`` when nobody has, or
2828    #: when the tallies are withheld (then ``solves`` is ``None`` too).
2829    first_solved_at: AwareDatetime | None = None
2830    #: What players said, one entry per reaction given, largest first.
2831    #: ``None`` when withheld: for ``solves``' reason, or because the
2832    #: viewer may not read reaction counts at all — those are a signed-in
2833    #: read (``submissions:read``, `routes.feedback.feedback_stats`), and a
2834    #: page a guest can open must not be the door around that.
2835    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):
1296class DeceptionStats(CtfyModel):
1297    """Three states, of which only two are counted.
1298
1299    ``contacted`` and ``complied`` are each a stored count of crossings;
1300    ``resisted`` is the difference. There is no third counter because
1301    there is no moment at which a refusal happens for something to
1302    count — resistance is the absence of compliance after contact, and
1303    an absence cannot be recorded at the time it fails to occur.
1304    """
1305
1306    contacted: int = 0
1307    complied: int = 0
1308    resisted: int = 0
1309    #: ``complied / contacted``. Absent rather than zero when nothing has
1310    #: been contacted: 0/0 is "no data", and rendering it as a 0% rate
1311    #: would assert that no agent was fooled, which is a claim the
1312    #: platform has not earned.
1313    comply_rate: float | None = None
1314    #: ⚠️ Rides on the wire so a reader cannot get the number without it.
1315    caveat: str = (
1316        "resisted is an upper bound: the probe runs inside a container the "
1317        "player can compromise, so a missing compliance record is not "
1318        "evidence of restraint."
1319    )

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):
380class DefenceInfo(CtfyModel):
381    """What a defender may do on this challenge — AWD+ only.
382
383    Present iff the challenge declares a ``patch:`` block. ``None``
384    everywhere else, which is what lets a client decide whether to render
385    a defence surface at all: without this the only way to find out was
386    to POST a submission and read the refusal, so the web UI had no way
387    to know AWD+ existed.
388
389    ``targets`` is deliberately public. It is a whitelist, not a secret —
390    a defender is *given* this source (they can read it in their own
391    box), the refusal message already names a rejected path, and a UI
392    that cannot say which files it will accept sends players guessing.
393    """
394
395    #: Challenge-relative paths a submission may replace. Anything else
396    #: is refused at submit time rather than at judge time.
397    targets: list[str] = Field(default_factory=list)
398    #: Whether ``patch.live`` declares a defender's shell, i.e. whether
399    #: ``ctfy patch shell`` / the terminal button will work here. False
400    #: means uploads are the only channel — a real and supported shape,
401    #: not a degraded one, so the UI says so rather than hiding.
402    live_shell: bool = False
403    #: The compose service the defender's shell opens in
404    #: (``patch.live.service``); ``""`` without a shell.
405    shell_service: str = ""
406    #: Whether that service is one the round loop **scores** — attacks and
407    #: probes. ⚠️ **False is a different box, not a smaller one**: a shell
408    #: in a sidecar that merely shares the source cannot restart, signal
409    #: or even reach the scored process, and a brief that said otherwise
410    #: sent agents hunting for a process that was not there.
411    shell_is_scored: bool = False
412    #: The command, run in the shell, that makes an edit take effect
413    #: (``patch.live.restart``). ``""`` = the challenge does not say —
414    #: *unknown*, never "edits reload themselves".
415    restart_command: str = ""
416    #: Where each patch target lives in the shell, and where its shipped
417    #: copy is. Only targets the live channel can reach are listed.
418    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):
368class DefenceLiveFile(CtfyModel):
369    """One patch target as the defender's shell sees it."""
370
371    #: Challenge-relative path, as in ``DefenceInfo.targets``.
372    target: str
373    #: Where it lives inside ``DefenceInfo.shell_service`` — the file to edit.
374    path: str
375    #: Where the shipped copy lives in the same service, to restore a
376    #: broken edit by hand; ``""`` when the challenge does not say.
377    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):
1551class DefenceMatrixResponse(CtfyModel):
1552    """Who has patched what — the defence-side twin of the solve heatmap.
1553
1554    ⚠️ **``challenge_ids`` is configuration, never data.** It is the
1555    competition's defensible challenges, so a challenge nobody has
1556    patched still gets a column — which is information, and which keeps
1557    the columns from re-ordering mid-event as submissions land. The same
1558    reasoning the wall's scene registry states for `hasChallenges`.
1559    """
1560
1561    challenge_ids: list[str] = Field(default_factory=list)
1562    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):
1531class DefenceMatrixRow(CtfyModel):
1532    """One team's line of the defence matrix."""
1533
1534    team_id: str = ""
1535    team_name: str = ""
1536    #: ⚠️ Present because a wire model that carries a team identity needs
1537    #: a **stated fate**, not a reader. `AwdBoardEntry` and
1538    #: `VirtualGhostRow` shipped without one and both boards rendered the
1539    #: raw tag; `test_anonymity_readers.py` pins the producer set for
1540    #: exactly that reason.
1541    anonymous: bool = False
1542    #: ``{challenge_id: verdict}``.
1543    #:
1544    #: ⚠️ **A pair with no cell is absent, never a present ``""``.** That
1545    #: is `fold_defence_cells`' decision carried onto the wire unchanged:
1546    #: "not judged yet" and "never submitted" are one empty cell to a
1547    #: reader, so a third state would be one nothing draws differently.
1548    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):
338class DefenceSource(CtfyModel):
339    """The patchable source of an AWD+ challenge, as the defender gets it.
340
341    AWD+ hands every team the same vulnerable service **and its source**
342    (§15.1) — you cannot patch what you cannot read. Until this there
343    was no way to get it: the only channel was `patch.live`, i.e. ssh
344    into your own box, which is the *classic AWD* shape and leaves
345    virtual participation and upsolve impossible, since after the event
346    there is no box to enter. That is the feature §15.5 calls decisive
347    for a weekly series.
348
349    ⚠️ **Exactly `patch.targets`, never the tree.** A benchmark
350    directory also holds `solution/exploit.py` and the checker, and for
351    some challenges the groundtruth — serving the directory would hand a
352    player the reference exploit and the thing that grades them. The
353    whitelist is already the format's one real security boundary (see
354    `routes/patches.py`), so the download reuses it rather than
355    inventing a second, weaker one.
356
357    ``files`` is keyed by challenge-relative path with **base64**
358    values, which is deliberately the shape `POST /patches` accepts: a
359    defender — or an agent — round-trips download → edit → upload with
360    no transformation, and a binary target does not need a second code
361    path.
362    """
363
364    challenge_id: str
365    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):
624class DeleteMeRequest(CtfyModel):
625    """Body for ``DELETE /me``. ``confirm_display_name`` must match the
626    caller's user display name exactly (case-sensitive) — the typo
627    gate that stops a fat-fingered click from cascading the account's
628    data away. (Falls back to email when display_name is empty.)
629
630    ⚠️ **The typo gate is not the authorization half and never was.** A
631    display name is public — it is on the caller's own profile page and
632    on ``/me`` — so a stolen session reads it in the same request. The
633    second factor is ``current_password`` or ``code``, whichever
634    :func:`~ctfy.server.account_deletion.required_proof` says this
635    account can give; ``MeResponse.deletion_proof`` publishes which, so
636    a client renders one control rather than guessing.
637
638    ⚠️ **Both are optional here rather than one being required**, because
639    which one applies is a property of the account, not of the request —
640    and an account whose answer is ``none`` sends neither, which is
641    every account before this field existed.
642    """
643
644    confirm_display_name: str = Field(min_length=1, max_length=120)
645    #: Re-entered password, for an account holding a password identity.
646    current_password: str = Field(default="", max_length=256)
647    #: The six digits mailed by ``POST /me/deletion-code``, for an
648    #: account with no password and a proved address.
649    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):
430class DeploymentConfig(CtfyModel):
431    """Read-only deployment posture for the admin overview — env-only
432    knobs that are *not* on public ``/meta`` and not runtime-tunable via
433    platform-settings, so an operator can see how this deployment is
434    configured at a glance. ``rate_limiting_enabled`` is deliberately
435    absent: it is runtime-tunable, so the platform-settings page owns its
436    effective value + source tag. Defaults mirror ``CtfyConfig``."""
437
438    single_process_guard: bool = True
439    auto_migrate: bool = True
440    password_auth_enabled: bool = True
441    # HSTS is emitted only when the deployment is reachable over https
442    # (``oauth_redirect_base`` is https) — see security_headers.
443    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):
315class DeviceApproveRequest(CtfyModel):
316    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):
289class DeviceCodeResponse(CtfyModel):
290    """RFC 8628-style response to ``POST /auth/device/code`` — the CLI
291    shows ``user_code`` + ``verification_uri`` and polls with
292    ``device_code``."""
293
294    device_code: str
295    user_code: str
296    verification_uri: str
297    verification_uri_complete: str
298    expires_in: int
299    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):
319class DeviceInfoResponse(CtfyModel):
320    """What the approval page shows about a pending device request."""
321
322    status: str
323    requested_at: datetime | None = None
324    requester_ip: str = ""
325    requester_user_agent: str = ""
326
327    @computed_field
328    @property
329    def requester_location(self) -> IpLocation | None:
330        """Where ``requester_ip`` is (#2140), so the person approving a CLI
331        login can see it is not a stranger's city. ``None`` without an address."""
332        if not self.requester_ip:
333            return None
334        from ctfy.server.ip_location import locate_ip
335
336        return locate_ip(self.requester_ip)

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 = ''
requester_location: ctfy.core.ip_location.IpLocation | None
327    @computed_field
328    @property
329    def requester_location(self) -> IpLocation | None:
330        """Where ``requester_ip`` is (#2140), so the person approving a CLI
331        login can see it is not a stranger's city. ``None`` without an address."""
332        if not self.requester_ip:
333            return None
334        from ctfy.server.ip_location import locate_ip
335
336        return locate_ip(self.requester_ip)

Where requester_ip is (#2140), so the person approving a CLI login can see it is not a stranger's city. None without an address.

class DeviceTokenRequest(ctfy.core.models.CtfyModel):
302class DeviceTokenRequest(CtfyModel):
303    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):
306class DeviceTokenResponse(CtfyModel):
307    """Poll result for ``POST /auth/device/token``. ``status`` is one of
308    ``pending`` / ``approved`` / ``denied`` / ``expired``; ``token`` is
309    set only when ``approved``."""
310
311    status: str
312    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):
706class DifficultyStat(CtfyModel):
707    difficulty: Difficulty | Literal[""] = ""
708    solved: int = 0
709    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 DirectInviteSent(ctfy.core.models.CtfyModel):
577class DirectInviteSent(CtfyModel):
578    """What a captain is told after inviting an address (#2863).
579
580    ``POST /competitions/{id}/invites/direct``. ⚠️ **The same body whatever the
581    address is**: registered or not, already on a team or not, already invited
582    or not. Each of those used to be its own status (404 ``user_not_found``,
583    409 ``already_on_team``, 409 ``invite_already_pending``), which made the
584    form an oracle for whether an email address has an account here -- for
585    anybody, since anybody can captain a team of one. ``email`` is the address
586    as the server read it (trimmed), so the page can say where it went.
587    """
588
589    status: Literal["sent"] = "sent"
590    email: str

What a captain is told after inviting an address (#2863).

POST /competitions/{id}/invites/direct. ⚠️ The same body whatever the address is: registered or not, already on a team or not, already invited or not. Each of those used to be its own status (404 user_not_found, 409 already_on_team, 409 invite_already_pending), which made the form an oracle for whether an email address has an account here -- for anybody, since anybody can captain a team of one. email is the address as the server read it (trimmed), so the page can say where it went.

status: Literal['sent'] = 'sent'
email: str = PydanticUndefined
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 EligibilityImpact(ctfy.core.models.CtfyModel):
123class EligibilityImpact(CtfyModel):
124    """What a set of domains means for the people already registered.
125
126    ``not_meeting`` is the organiser's callout -- *N already-registered people
127    do not meet it* -- and is ``lapsed`` of the roster: neither holding a
128    qualifying proved address nor let in by an organiser.
129    """
130
131    competition_id: str = ""
132    restricted: bool = False
133    total: int = 0
134    verified: int = 0
135    invited: int = 0
136    not_meeting: int = 0

What a set of domains means for the people already registered.

not_meeting is the organiser's callout -- N already-registered people do not meet it -- and is lapsed of the roster: neither holding a qualifying proved address nor let in by an organiser.

competition_id: str = ''
restricted: bool = False
total: int = 0
verified: int = 0
invited: int = 0
not_meeting: int = 0
class EligibleInstitution(ctfy.core.models.CtfyModel):
20class EligibleInstitution(CtfyModel):
21    """One catalogue domain a competition accepts, as a reader draws it.
22
23    ``names`` crosses as a map, resolved in the browser (the catalogue's own
24    decision). ⚠️ A domain the operator has since removed from the catalogue
25    still travels, with no names: the competition's stored fact is the
26    domain, and dropping the row would silently widen who the page says may
27    register.
28    """
29
30    domain: str = ""
31    names: dict[str, str] = Field(default_factory=dict)
32    short_name: str = ""

One catalogue domain a competition accepts, as a reader draws it.

names crosses as a map, resolved in the browser (the catalogue's own decision). ⚠️ A domain the operator has since removed from the catalogue still travels, with no names: the competition's stored fact is the domain, and dropping the row would silently widen who the page says may register.

domain: str = ''
names: dict[str, str] = PydanticUndefined
short_name: str = ''
class EmailCodeAuthInfo(ctfy.core.models.CtfyModel):
57class EmailCodeAuthInfo(CtfyModel):
58    """Whether passwordless email sign-in is offered.
59
60    Tracks outbound-email configuration exactly — there is no separate
61    feature flag, because a sign-in method that cannot deliver its code
62    is not a sign-in method, and offering the button anyway just produces
63    a dead end.
64    """
65
66    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):
162class EmailSuppressionCreate(CtfyModel):
163    email: str
164    #: Free text; stored as the ``detail`` an operator reads later when
165    #: deciding whether the entry still applies.
166    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):
136class EmailSuppressionInfo(CtfyModel):
137    """One address the platform will not send to."""
138
139    email: str
140    #: ``hard_bounce`` | ``complaint`` | ``manual``.
141    reason: str
142    #: The provider's own words, when it gave any.
143    detail: str = ""
144    #: ``webhook`` | ``admin`` — a provider's verdict or a human's.
145    source: str = ""
146    #: The message whose bounce caused this, for cross-referencing the
147    #: outbox row that sent it.
148    provider_message_id: str = ""
149    created_at: str = ""
150    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):
153class EmailSuppressionListResponse(CtfyModel):
154    items: list[EmailSuppressionInfo]
155    total: int
156    #: False when no Svix secret is set, in which case nothing will ever
157    #: be added automatically and the list is manual-only. The UI says so
158    #: rather than letting an operator conclude that nothing bounces.
159    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):
113class EmailTemplateInfo(CtfyModel):
114    """One row of the admin template list.
115
116    ``category`` and ``forced`` come from the same registry the
117    preference matrix reads, so the admin sees *why* a message is or is
118    not suppressible without cross-referencing another page.
119    """
120
121    name: str
122    category: str
123    forced: bool
124    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):
127class EmailTemplateListResponse(CtfyModel):
128    templates: list[EmailTemplateInfo]
129    #: False when Resend is unconfigured. The UI disables Send rather
130    #: than letting the operator discover it through a 400.
131    email_configured: bool
132    #: Where a test send goes when the request omits a recipient.
133    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):
174class EmailVerificationSent(CtfyModel):
175    """Answer to ``POST /auth/email-verification`` and ``POST /me/emails``.
176
177    ``handover`` says what redeeming the code that was just sent would
178    do to *another* account, so the person can be told before they go to
179    their inbox rather than after.
180
181    ⚠️ **The default is ``"none"`` and that is safe because the gate is
182    elsewhere.** A producer that forgets, or an older server a newer
183    client is talking to, reports an ordinary claim — and the only
184    destructive outcome, orphaning the other account, is refused at the
185    *exchange* unless that request carries an explicit acknowledgement.
186    So silence here costs a missing warning, never a silent taking; the
187    reverse default would be a new claim about somebody else's account.
188    """
189
190    sent: bool = True
191    expires_in_minutes: int = 0
192    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):
195class EmailVerificationStatus(CtfyModel):
196    """Where an account stands on proving its address.
197
198    ``verified`` is a ``computed_field`` rather than a stored bool
199    because this is a wire model and the two must never be able to
200    disagree — one fact, published in the shape a client wants to read.
201    """
202
203    email: str = ""
204    verified_at: AwareDatetime | None = None
205    #: When the code that is still redeemable was mailed, and when it
206    #: stops being. ``None`` = no code is outstanding (never sent, the
207    #: send failed, it expired, was used up or locked out) -- the UI says
208    #: "send a code" rather than "resend". ⚠️ Read from the code row
209    #: itself, never guessed in the browser from a timer.
210    code_sent_at: AwareDatetime | None = None
211    code_expires_at: AwareDatetime | None = None
212
213    @computed_field
214    @property
215    def verified(self) -> bool:
216        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
code_sent_at: pydantic.types.AwareDatetime | None = None
code_expires_at: pydantic.types.AwareDatetime | None = None
verified: bool
213    @computed_field
214    @property
215    def verified(self) -> bool:
216        return self.verified_at is not None
class EnvSharingChallengeRow(ctfy.core.models.CtfyModel):
29class EnvSharingChallengeRow(CtfyModel):
30    """One challenge of the competition and how its environment is shared."""
31
32    challenge_id: str
33    name: str = ""
34    #: What the author declared (``x-ctfy-sharing``). ``undeclared`` is a
35    #: warning when shared, never a block.
36    declared: ChallengeSharing = ChallengeSharing.UNDECLARED
37    #: Why it can never be shared (``engagement`` / ``defence`` / ``judged``),
38    #: ``""`` when it can. An override into a shared mode is refused.
39    blocker: ShareBlocker = ""
40    #: The organiser's override for this challenge; ``None`` follows the
41    #: competition's default.
42    override: EnvShareMode | None = None
43    #: What the challenge actually runs under (blocked challenges are
44    #: ``per_team`` whatever the default says).
45    effective: EnvShareMode = EnvShareMode.PER_TEAM

One challenge of the competition and how its environment is shared.

challenge_id: str = PydanticUndefined
name: str = ''
declared: ctfy.core.enums.ChallengeSharing = <ChallengeSharing.UNDECLARED: 'undeclared'>
blocker: Literal['', 'engagement', 'defence', 'judged'] = ''
override: ctfy.core.enums.EnvShareMode | None = None
effective: ctfy.core.enums.EnvShareMode = <EnvShareMode.PER_TEAM: 'per_team'>
class EnvSharingGrouping(ctfy.core.models.CtfyModel):
48class EnvSharingGrouping(CtfyModel):
49    """The state of the team grouping."""
50
51    method: GroupingMethod = GroupingMethod.RANDOM
52    by_field: str = ""
53    seed: int = 0
54    #: Whether any team has a group yet.
55    assigned: bool = False
56    assigned_at: AwareDatetime | None = None
57    group_count: int = 0
58    #: Teams that have a group.
59    assigned_teams: int = 0
60    #: The ``group_size`` the table was dealt with (``0`` = never).
61    assigned_group_size: int = 0
62    #: The configured size has moved since the table was dealt; re-draw to
63    #: apply it. Never silent: a slider does not redeal a fixed table.
64    stale: bool = False
65    #: ``{registration field: distinct values}`` for the fields that can
66    #: separate teams -- only those with more than one value (a school
67    #: competition's school field has one, and is absent).
68    selectable_fields: dict[str, int] = Field(default_factory=dict)
69    #: ``{registration field: distinct values}`` for **every** field a draw
70    #: may spread by, including those with one value or none -- the page
71    #: lists them disabled, with the reason, so an organiser of a school
72    #: competition sees why the school field is not an option.
73    field_values: dict[str, int] = Field(default_factory=dict)
74    #: Whether a group already has a running or stored shared environment, so
75    #: a re-draw would orphan it (and needs ``force``).
76    in_use: bool = False

The state of the team grouping.

method: ctfy.core.enums.GroupingMethod = <GroupingMethod.RANDOM: 'random'>
by_field: str = ''
seed: int = 0
assigned: bool = False
assigned_at: pydantic.types.AwareDatetime | None = None
group_count: int = 0
assigned_teams: int = 0
assigned_group_size: int = 0
stale: bool = False
selectable_fields: dict[str, int] = PydanticUndefined
field_values: dict[str, int] = PydanticUndefined
in_use: bool = False
class EnvSharingUpdate(ctfy.core.models.CtfyModel):
 96class EnvSharingUpdate(CtfyModel):
 97    """``PATCH``: only what is sent changes.
 98
 99    ``overrides`` maps a challenge id to a mode, or to ``null`` to follow the
100    competition again. After the competition has started a change needs
101    ``confirm``; it never touches an environment already running.
102    """
103
104    mode: EnvShareMode | None = None
105    group_size: int | None = Field(default=None, ge=2, le=1000)
106    overrides: dict[str, EnvShareMode | None] = Field(default_factory=dict)
107    confirm: bool = False

PATCH: only what is sent changes.

overrides maps a challenge id to a mode, or to null to follow the competition again. After the competition has started a change needs confirm; it never touches an environment already running.

mode: ctfy.core.enums.EnvShareMode | None = None
group_size: int | None = None
overrides: dict[str, ctfy.core.enums.EnvShareMode | None] = PydanticUndefined
confirm: bool = False
class EnvSharingView(ctfy.core.models.CtfyModel):
79class EnvSharingView(CtfyModel):
80    """The organiser's reading of a competition's environment sharing."""
81
82    competition_id: str
83    mode: EnvShareMode = EnvShareMode.PER_TEAM
84    group_size: int = 10
85    team_count: int = 0
86    #: Environments per challenge each mode would cost for ``team_count``
87    #: teams -- the number on the three tiles.
88    environments: dict[EnvShareMode, int] = Field(default_factory=dict)
89    #: Whether the competition has started: a change then asks to be
90    #: confirmed and only affects environments started afterwards.
91    started: bool = False
92    challenges: list[EnvSharingChallengeRow] = Field(default_factory=list)
93    grouping: EnvSharingGrouping = Field(default_factory=EnvSharingGrouping)

The organiser's reading of a competition's environment sharing.

competition_id: str = PydanticUndefined
mode: ctfy.core.enums.EnvShareMode = <EnvShareMode.PER_TEAM: 'per_team'>
group_size: int = 10
team_count: int = 0
environments: dict[ctfy.core.enums.EnvShareMode, int] = PydanticUndefined
started: bool = False
challenges: list[EnvSharingChallengeRow] = PydanticUndefined
grouping: EnvSharingGrouping = PydanticUndefined
class ErrorResponse(ctfy.core.models.CtfyModel):
523class ErrorResponse(CtfyModel):
524    """Unified shape for every HTTP 4xx/5xx body produced by platform routes.
525
526    ``detail`` mirrors the pre-existing field (so SDK / CLI / frontend code
527    that reads ``data.detail`` keeps working); ``code`` adds a stable
528    machine-readable identifier derived from the raising exception class,
529    and ``timestamp`` is server-side UTC in ISO 8601 — useful when
530    correlating client-side and server-side logs.
531
532    ``detail`` is a union because three sources feed the envelope:
533      * a plain string from legacy ``HTTPException`` / ``raise_error`` calls;
534      * a structured dict (``{"message": "...", "competition_id": ...}``
535        or the legacy ``{"error": "..."}``) when the route wants to attach
536        machine-readable context the frontend can branch on;
537      * a list of FastAPI ``RequestValidationError`` issues, wrapped under
538        ``{"message", "issues"}`` by ``_handle_validation_error``.
539    """
540
541    code: str
542    detail: str | dict[str, Any] | list[dict[str, Any]]
543    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):
750class EvalAllowanceGrant(CtfyModel):
751    """A grant as an operator types it."""
752
753    user_id: str = Field(min_length=1)
754    competition_id: str = Field(min_length=1)
755    #: ⚠️ ``0`` is allowed and funds nothing — an operator can create the
756    #: row before deciding the amount. Negative is not: it would read as
757    #: a debt and ``has_room`` would refuse it anyway, so the form says
758    #: so instead of storing a number nothing can mean.
759    cap_usd: float = Field(ge=0.0)
760    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):
711class EvalAllowanceRow(CtfyModel):
712    """One grant, as an operator or its holder reads it.
713
714    ⚠️ ``remaining_usd`` and ``exhausted`` are **computed here**, on a
715    wire model, from the same arithmetic the dispatch uses. A client
716    subtracting for itself would eventually disagree with the server
717    about whether a run may start — and it is the server's refusal that
718    decides.
719    """
720
721    user_id: str = ""
722    #: The account's own name, so an operator's list is readable without
723    #: a second lookup per row.
724    display_name: str = ""
725    competition_id: str = ""
726    competition_title: str = ""
727    #: The competition's title per locale (#2230); a browser renders this,
728    #: ``competition_title`` is its base-locale reading.
729    competition_title_i18n: LocalizedText = Field(default_factory=dict)
730    cap_usd: float = 0.0
731    spent_usd: float = 0.0
732    granted_by_user_id: str = ""
733    granted_at: str = ""
734    note: str = ""
735
736    @computed_field
737    @property
738    def remaining_usd(self) -> float:
739        """⚠️ Clamped at zero. An overshoot is real and stays in
740        ``spent_usd``; a negative remainder invites a reader to add it to
741        the next grant."""
742        return Allowance(cap_usd=self.cap_usd, spent_usd=self.spent_usd).remaining_usd
743
744    @computed_field
745    @property
746    def exhausted(self) -> bool:
747        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 = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
cap_usd: float = 0.0
spent_usd: float = 0.0
granted_by_user_id: str = ''
granted_at: str = ''
note: str = ''
remaining_usd: float
736    @computed_field
737    @property
738    def remaining_usd(self) -> float:
739        """⚠️ Clamped at zero. An overshoot is real and stays in
740        ``spent_usd``; a negative remainder invites a reader to add it to
741        the next grant."""
742        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
744    @computed_field
745    @property
746    def exhausted(self) -> bool:
747        return not Allowance(cap_usd=self.cap_usd, spent_usd=self.spent_usd).has_room
class EvalArtifactBucket(ctfy.core.models.CtfyModel):
2706class EvalArtifactBucket(CtfyModel):
2707    """One column of the archive-size histogram."""
2708
2709    label: str = ""
2710    runs: int = 0

One column of the archive-size histogram.

label: str = ''
runs: int = 0
class EvalArtifactReport(ctfy.core.models.CtfyModel):
2713class EvalArtifactReport(CtfyModel):
2714    """How much disk the collected agent archives are using.
2715
2716    ``docs/agent-artifact-plan.md`` §7 sized the archive from one half of
2717    it: the session half was measured, the working-directory half never
2718    was, and it is unbounded by construction. Both the per-archive cap
2719    and the retention window were chosen against that gap — and the
2720    retention default is *forever*, so nothing reclaims an archive until
2721    somebody sets one. This is the number that turns those into decisions
2722    somebody can make.
2723
2724    ⚠️ **``by_status`` is the reason the headline is what it is.** Zero
2725    stored bytes is four different facts — nobody has run an agent, this
2726    deployment has no bucket (``not_configured``), collection is failing
2727    (``capture_failed`` / ``upload_failed``), or a window reclaimed them
2728    (``reclaimed``) — and they read identically as a single ``0``. The
2729    same shape the registry usage report needed its scan receipt for.
2730    """
2731
2732    stored_runs: int = 0
2733    stored_bytes: int = 0
2734    largest_bytes: int = 0
2735    buckets: list[EvalArtifactBucket] = Field(default_factory=list)
2736    #: ``{artifact_status: runs}`` over **every** run, archive or not.
2737    by_status: dict[str, int] = Field(default_factory=dict)
2738    #: The window in force, echoed so the page does not restate the
2739    #: setting resolver's precedence (DB > env > default) in a browser.
2740    #: ``0`` is forever, and forever is the shipped default.
2741    retention_s: int = 0
2742    #: Whether this deployment can keep an archive at all. ⚠️ Server-side
2743    #: and defaulting **False** — a client reading it must never be the
2744    #: one deciding, and an older server must not claim a bucket it
2745    #: never reported.
2746    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 EvalBatchCancelResult(ctfy.core.models.CtfyModel):
2418class EvalBatchCancelResult(CtfyModel):
2419    """``POST /eval/batches/{id}/cancel``: the campaign cancel, applied to
2420    every cell still running (#2677). One ``EvalQueueChange`` per cell it
2421    cancelled; ``skipped`` names the cells that had already finished."""
2422
2423    batch_id: str = ""
2424    changes: list[EvalQueueChange] = Field(default_factory=list)
2425    skipped: list[str] = Field(default_factory=list)

POST /eval/batches/{id}/cancel: the campaign cancel, applied to every cell still running (#2677). One EvalQueueChange per cell it cancelled; skipped names the cells that had already finished.

batch_id: str = ''
changes: list[EvalQueueChange] = PydanticUndefined
skipped: list[str] = PydanticUndefined
class EvalBatchLimits(ctfy.core.models.CtfyModel):
2383class EvalBatchLimits(CtfyModel):
2384    """What the wizard needs before a batch is submitted (#2677)."""
2385
2386    #: Most cells (agents × models) one batch may hold
2387    #: (``eval_batch_max_cells``). A larger one is ``409 eval_batch_too_large``.
2388    max_cells: int = 0
2389    #: Ranges your runs may hold at once across every campaign of yours
2390    #: (``eval_instances_per_user``) — a batch shares them, never adds to them.
2391    slots: int = 0

What the wizard needs before a batch is submitted (#2677).

max_cells: int = 0
slots: int = 0
class EvalBatchModel(ctfy.core.models.CtfyModel):
2348class EvalBatchModel(CtfyModel):
2349    """One model column of a batch, with who pays for it (#2677).
2350
2351    The payer is per model because a saved connection drives one provider:
2352    the same choice as the wizard's model step, made once per column.
2353    ``auto`` is refused here — a batch always says who pays.
2354    """
2355
2356    model_id: str = Field(min_length=1)
2357    funding_source: Literal["platform", "byok"]
2358    #: Saved connection id (``GET /eval/llm-keys``); required for ``byok``.
2359    llm_connection_id: str = ""

One model column of a batch, with who pays for it (#2677).

The payer is per model because a saved connection drives one provider: the same choice as the wizard's model step, made once per column. auto is refused here — a batch always says who pays.

model_id: str = PydanticUndefined
funding_source: Literal['platform', 'byok'] = PydanticUndefined
llm_connection_id: str = ''
class EvalBatchRequest(ctfy.core.models.CtfyModel):
2362class EvalBatchRequest(CtfyModel):
2363    """``POST /eval/batches``: every agent × every model on one target (#2677).
2364
2365    One campaign per cell, each exactly the entry ``POST /eval/entries``
2366    would make for that pair. The settings apply to every cell;
2367    ``scheduling_cost_limit_usd`` is per cell, like an entry's.
2368    """
2369
2370    competition_id: str = Field(min_length=1)
2371    #: Your agents, the rows. Duplicates are refused, never collapsed.
2372    harness_ids: list[str] = Field(min_length=1)
2373    #: The columns.
2374    models: list[EvalBatchModel] = Field(min_length=1)
2375    scheduling_cost_limit_usd: float = Field(default=0, ge=0, allow_inf_nan=False)
2376    num_samples: int = Field(default=1, ge=1)
2377    timeout_s: int = Field(default=0, ge=0)
2378    max_turns: int = Field(default=0, ge=0)
2379    #: A trial over these challenges for every cell (#2551); ``[]`` = all.
2380    challenge_ids: list[str] = Field(default_factory=list, max_length=500)

POST /eval/batches: every agent × every model on one target (#2677).

One campaign per cell, each exactly the entry POST /eval/entries would make for that pair. The settings apply to every cell; scheduling_cost_limit_usd is per cell, like an entry's.

competition_id: str = PydanticUndefined
harness_ids: list[str] = PydanticUndefined
models: list[EvalBatchModel] = PydanticUndefined
scheduling_cost_limit_usd: float = 0
num_samples: int = 1
timeout_s: int = 0
max_turns: int = 0
challenge_ids: list[str] = PydanticUndefined
class EvalBatchState(ctfy.core.models.CtfyModel):
2394class EvalBatchState(CtfyModel):
2395    """One batch: its cells' campaigns, as their owner reads them (#2677).
2396
2397    ⚠️ **A batch is a label on campaigns, not a scheduler.** Each cell is an
2398    ordinary campaign, dispatched under the owner's fair share like any
2399    other (#2585), so a batch never jumps the queue.
2400    """
2401
2402    id: str = ""
2403    competition_id: str = ""
2404    #: The rows and columns, in the order the request named them.
2405    harness_ids: list[str] = Field(default_factory=list)
2406    model_ids: list[str] = Field(default_factory=list)
2407    #: One campaign per cell (``batch_id`` = this id). Its ``task_id`` opens
2408    #: the campaign page.
2409    cells: list[EvalCampaignState] = Field(default_factory=list)
2410    #: Difficulty of each challenge the cells' tasks name, for the per-cell
2411    #: breakdown. A challenge no longer in the catalog is absent.
2412    difficulty: dict[str, Difficulty] = Field(default_factory=dict)
2413    created_at_ts: float = 0.0
2414    #: Whether this caller may cancel (the batch's owner or a platform admin).
2415    can_cancel: bool = False

One batch: its cells' campaigns, as their owner reads them (#2677).

⚠️ A batch is a label on campaigns, not a scheduler. Each cell is an ordinary campaign, dispatched under the owner's fair share like any other (#2585), so a batch never jumps the queue.

id: str = ''
competition_id: str = ''
harness_ids: list[str] = PydanticUndefined
model_ids: list[str] = PydanticUndefined
cells: list[ctfy.core.state.models.EvalCampaignState] = PydanticUndefined
difficulty: dict[str, ctfy.challenge.benchmark.Difficulty] = PydanticUndefined
created_at_ts: float = 0.0
can_cancel: bool = False
class EvalBoardScope(ctfy.core.models.CtfyModel):
1344class EvalBoardScope(CtfyModel):
1345    """What this board is a board *of*, and how fresh it is (#2733).
1346
1347    ⚠️ Every field is the server's: the revision a reader asked for and the
1348    one the runs actually share, whether they are mixed or unrecorded, the
1349    manifest the coverage is over, and the filters and window applied.
1350    """
1351
1352    tier: Literal["official", "self_reported", "all"] = "official"
1353    #: The ``corpus_sha`` asked for; ``""`` = every revision.
1354    requested_revision: str = ""
1355    #: The one revision every counted run recorded; ``""`` unless
1356    #: ``revision_state`` is ``single``.
1357    effective_revision: str = ""
1358    revision_state: RevisionState = "empty"
1359    #: Counted runs that recorded no revision at all.
1360    unrecorded_revision_runs: int = 0
1361    #: The revisions this scope's runs were graded on, ignoring the
1362    #: ``corpus_sha`` filter, so a reader can pick another.
1363    revisions: list[BoardRevision] = Field(default_factory=list)
1364    #: The revision this deployment serves; ``""`` when unknown.
1365    catalog_revision: str = ""
1366    #: The suite's own pin; ``""`` when it pools every revision.
1367    pinned_revision: str = ""
1368    #: The revision the manifest below describes; ``""`` when there is none.
1369    manifest_revision: str = ""
1370    #: The challenges the board's revision ranks (its set less the free
1371    #: samples), and the identity of that set. ⚠️ ``None`` / ``""`` whenever
1372    #: the board's revision is not the one this deployment serves: the
1373    #: platform keeps no per-revision history, so any other count would be
1374    #: today's catalog relabelled.
1375    manifest_count: int | None = None
1376    manifest_digest: str = ""
1377    #: The suite's rankable challenges in the current catalog -- its set less
1378    #: the free samples, which no board counts. A current figure, labelled so.
1379    eligible_challenge_count: int = 0
1380    #: The free samples left out of ``eligible_challenge_count``.
1381    preview_challenge_count: int = 0
1382    #: How long a computed board is served from the cache, in seconds; the
1383    #: figures are at most this much older than ``generated_at`` says.
1384    cache_ttl_seconds: float = 0.0
1385    #: The filters applied (#2733). ``q`` is a model search; ``vendor`` an
1386    #: exact vendor slug; ``harness_id`` scopes the *aggregate* to one
1387    #: harness (``None`` = every harness, ``""`` = the built-in one), so the
1388    #: merged rows and every rank are recomputed over its runs alone.
1389    q: str = ""
1390    vendor: str = ""
1391    harness_id: str | None = None
1392    offset: int = 0
1393    #: ``None`` = every matching row.
1394    limit: int | None = None
1395    totals: BoardTotals = Field(default_factory=BoardTotals)
1396    harness_options: list[BoardHarnessOption] = Field(default_factory=list)
1397    vendor_options: list[BoardVendorOption] = Field(default_factory=list)

What this board is a board of, and how fresh it is (#2733).

⚠️ Every field is the server's: the revision a reader asked for and the one the runs actually share, whether they are mixed or unrecorded, the manifest the coverage is over, and the filters and window applied.

tier: Literal['official', 'self_reported', 'all'] = 'official'
requested_revision: str = ''
effective_revision: str = ''
revision_state: Literal['empty', 'single', 'mixed', 'unknown'] = 'empty'
unrecorded_revision_runs: int = 0
revisions: list[BoardRevision] = PydanticUndefined
catalog_revision: str = ''
pinned_revision: str = ''
manifest_revision: str = ''
manifest_count: int | None = None
manifest_digest: str = ''
eligible_challenge_count: int = 0
preview_challenge_count: int = 0
cache_ttl_seconds: float = 0.0
q: str = ''
vendor: str = ''
harness_id: str | None = None
offset: int = 0
limit: int | None = None
totals: BoardTotals = PydanticUndefined
harness_options: list[BoardHarnessOption] = PydanticUndefined
vendor_options: list[BoardVendorOption] = PydanticUndefined
class EvalCampaignAtomicSummary(ctfy.core.models.CtfyModel):
2322class EvalCampaignAtomicSummary(CtfyModel):
2323    """A campaign's atomic tasks at a glance (``GET /eval/campaigns/{id}/summary``)."""
2324
2325    campaign_id: str = ""
2326    status: str = ""
2327    #: ``drain`` / ``now`` while a cancel is in force; ``""`` otherwise.
2328    cancel_mode: Literal["", "drain", "now"] = ""
2329    #: Every row, superseded ones included.
2330    total: int = 0
2331    #: Every stage with its count (zeros included), over the rows that are
2332    #: still samples — a superseded row is counted in ``superseded`` only.
2333    stage_counts: dict[AtomicStage, int] = Field(default_factory=dict)
2334    superseded: int = 0
2335    #: Every row a server restart ended (#2983), superseded ones included:
2336    #: what deploys cost this campaign. ``stage_counts["interrupted"]`` is
2337    #: the part still waiting for "rerun unfinished" — the rest were queued
2338    #: again automatically.
2339    interrupted_total: int = 0
2340    #: A cancelled task nothing has filled: the campaign is off the official
2341    #: leaderboard until "rerun unfinished" queues one per cancelled task.
2342    incomplete: bool = False
2343    #: Whether this caller may cancel / rerun (the campaign's owner or a
2344    #: platform admin).
2345    can_cancel: bool = False

A campaign's atomic tasks at a glance (GET /eval/campaigns/{id}/summary).

campaign_id: str = ''
status: str = ''
cancel_mode: Literal['', 'drain', 'now'] = ''
total: int = 0
stage_counts: dict[ctfy.core.eval_atomic.AtomicStage, int] = PydanticUndefined
superseded: int = 0
interrupted_total: int = 0
incomplete: bool = False
can_cancel: bool = False
class EvalCancelRequest(ctfy.core.models.CtfyModel):
2290class EvalCancelRequest(CtfyModel):
2291    """``POST /eval/runs/{id}/cancel`` and ``/eval/campaigns/{id}/cancel``.
2292
2293    ``mode=drain`` (default) cancels what is queued and lets started work
2294    finish; ``now`` also stops the agent and reclaims the range of every
2295    task that is starting or running. ``requeue`` (a run only) appends a
2296    fresh queued row for the same challenge at the end of the queue.
2297    """
2298
2299    mode: CancelMode = "drain"
2300    requeue: bool = False

POST /eval/runs/{id}/cancel and /eval/campaigns/{id}/cancel.

mode=drain (default) cancels what is queued and lets started work finish; now also stops the agent and reclaims the range of every task that is starting or running. requeue (a run only) appends a fresh queued row for the same challenge at the end of the queue.

mode: Literal['drain', 'now'] = 'drain'
requeue: bool = False
class EvalEntryRequest(ctfy.core.models.CtfyModel):
806class EvalEntryRequest(CtfyModel):
807    """An entrant's own evaluation, with optional explicit funding.
808
809    Defaults preserve existing automatic BYOK/allowance resolution. Explicit
810    BYOK names a saved compatible connection; explicit platform funding uses
811    an existing organiser allowance. Neither explicit choice falls back.
812    The caller cannot set ownership, tier or vendor metadata. Scheduling cost
813    thresholds stop new dispatches and are not hard dollar budgets. Timeout
814    requests may only tighten deployment ceilings.
815    """
816
817    funding_source: EvalFundingSource = "auto"
818    #: Saved connection ID from GET /eval/llm-keys; required for explicit BYOK.
819    llm_connection_id: str = ""
820    #: Stops scheduling after recorded spend reaches this threshold. In-flight
821    #: runs may finish above it; this is not a hard dollar cap. Zero disables it.
822    scheduling_cost_limit_usd: float = Field(default=0, ge=0, allow_inf_nan=False)
823    harness_id: str = Field(min_length=1)
824    harness_version_id: str = ""
825    #: A registered model (``GET /eval/models``). Exactly one of this and
826    #: ``provider_model`` is set.
827    model_id: str = ""
828    #: #3471: the provider's own model id, typed by the caller, for a run on
829    #: their own key — the provider is the saved connection's
830    #: (``llm_connection_id``), so this needs ``funding_source="byok"``.
831    #: The platform makes one live call with that key and model before
832    #: anything starts; a refusal names what the provider said
833    #: (``byok_model_check_*``). ``""`` names a registered model instead.
834    provider_model: str = Field(default="", max_length=400)
835    competition_id: str = Field(min_length=1)
836    #: ⚠️ Clamped by the route, not trusted from here. pass@k multiplies
837    #: the fan-out, so this is the field that turns one submission into
838    #: an unbounded number of container runs on somebody else's nodes.
839    num_samples: int = Field(default=1, ge=1)
840    #: ⚠️ ``0`` inherits the deployment's setting on both, never
841    #: "unbounded" — the reading ``max_cost_usd`` takes on the operator's
842    #: form and the opposite of what is safe here. The wall clock is
843    #: additionally clamped at dispatch, so this can shorten a run and
844    #: never lengthen one.
845    timeout_s: int = Field(default=0, ge=0)
846    max_turns: int = Field(default=0, ge=0)
847    #: A trial run (#2551): only these challenges of the target, pinned on
848    #: the campaign. ``[]`` is the whole set. Each must be in the set the
849    #: caller may enter (``422 eval_trial_challenge_not_in_target``); a
850    #: subset that covers the whole set is the whole set. A trial ranks on
851    #: no board (``sandbox`` tier).
852    challenge_ids: list[str] = Field(default_factory=list, max_length=500)
853
854    @model_validator(mode="after")
855    def _one_model(self) -> EvalEntryRequest:
856        # ⚠️ Both is a request the route would have to resolve by picking
857        # one, and neither has nothing to run; refused rather than guessed.
858        if bool(self.model_id.strip()) == bool(self.provider_model.strip()):
859            raise ValueError("name exactly one of model_id and provider_model")
860        return self

An entrant's own evaluation, with optional explicit funding.

Defaults preserve existing automatic BYOK/allowance resolution. Explicit BYOK names a saved compatible connection; explicit platform funding uses an existing organiser allowance. Neither explicit choice falls back. The caller cannot set ownership, tier or vendor metadata. Scheduling cost thresholds stop new dispatches and are not hard dollar budgets. Timeout requests may only tighten deployment ceilings.

funding_source: Literal['auto', 'byok', 'platform'] = 'auto'
llm_connection_id: str = ''
scheduling_cost_limit_usd: float = 0
harness_id: str = PydanticUndefined
harness_version_id: str = ''
model_id: str = ''
provider_model: str = ''
competition_id: str = PydanticUndefined
num_samples: int = 1
timeout_s: int = 0
max_turns: int = 0
challenge_ids: list[str] = PydanticUndefined
class EvalLeaderboard(ctfy.core.models.CtfyModel):
1400class EvalLeaderboard(CtfyModel):
1401    """The full leaderboard: ranked model standings + the dimensions available."""
1402
1403    competition_id: str = ""
1404    challenge_count: int = 0
1405    #: the single corpus version all runs share (reproducibility header);
1406    #: blank when runs span mixed corpus revisions.
1407    corpus_sha: str = ""
1408    dimensions: list[str] = Field(default_factory=list)
1409    models: list[ModelStanding] = Field(default_factory=list)
1410    #: vendor scorecards (pooled across each vendor's models); unattributed
1411    #: models (blank vendor) are omitted.
1412    vendors: list[VendorStanding] = Field(default_factory=list)
1413    #: harness scorecards (pooled across each harness's runs) — the harness
1414    #: comparison axis. The blank harness id is the built-in ctfy harness.
1415    harnesses: list[HarnessStanding] = Field(default_factory=list)
1416    #: per-(model, harness) cross-tab cells — the apples-to-apples view that
1417    #: isolates the harness effect on a fixed model. Empty unless ≥2 harnesses.
1418    cross: list[HarnessModelCell] = Field(default_factory=list)
1419    #: One row per ``harness × model`` combination, ranked (#2341, L10).
1420    #: ⚠️ Emitted for a single harness too, unlike ``cross``: it is the
1421    #: suite page's default table, not a cross-tab.
1422    pairs: list[PairStanding] = Field(default_factory=list)
1423    #: The run count below which a row is flagged ``low_sample``, so the
1424    #: page states the threshold it was given rather than a copy of it.
1425    low_sample_runs: int = LOW_SAMPLE_RUNS
1426    #: The metrics every table here ranks by, in the order they are
1427    #: compared (:data:`~ctfy.core.eval_ranking.RANK_METRICS`), so the
1428    #: 「指标说明」 panel states the server's order rather than a copy of it.
1429    rank_order: list[RankMetric] = Field(default_factory=lambda: list(RANK_METRICS))
1430    #: run activity + run-level solve rate bucketed by UTC day (chronological).
1431    trend: list[EvalTrendPoint] = Field(default_factory=list)
1432    #: Combinations whose every counted run errored (#2733): on the board's
1433    #: page, never in its ranking. Newest first.
1434    unmeasured: list[UnmeasuredEntrant] = Field(default_factory=list)
1435    #: When the server computed this board (#2733). The board is served
1436    #: from a cache, so this is how old the figures are -- not when the page
1437    #: fetched them, which the page knows itself. ``None`` from a builder
1438    #: given no clock, never an epoch.
1439    generated_at: AwareDatetime | None = None
1440    #: The scope, freshness and window of this answer (#2733).
1441    scope: EvalBoardScope = Field(default_factory=EvalBoardScope)

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
pairs: list[PairStanding] = PydanticUndefined
low_sample_runs: int = 30
rank_order: list[typing.Literal['solved', 'solve_rate', 'tokens_per_solve', 'avg_turns', 'last_run_at', 'id']] = PydanticUndefined
trend: list[EvalTrendPoint] = PydanticUndefined
unmeasured: list[ctfy.server.models.eval.UnmeasuredEntrant] = PydanticUndefined
generated_at: pydantic.types.AwareDatetime | None = None
scope: EvalBoardScope = PydanticUndefined
class EvalMatchRoom(ctfy.core.models.CtfyModel):
1753class EvalMatchRoom(CtfyModel):
1754    """The numbers a match-time wait compares, read now (#3466).
1755
1756    While a match is played on the shared nodes a new eval range waits for
1757    one of two limits, and the page names which and by how much:
1758
1759    * ``match_cap`` -- evaluation already holds ``match_limit`` ranges, its
1760      match-time share (``match_pct`` percent of ``total_slots``,
1761      ``eval_match_capacity_pct``; ``core.eval_range_cap.eval_platform_ceiling``);
1762    * ``competition_priority`` -- ``free_slots`` is below ``reserve_pct``
1763      percent of ``total_slots`` (``core.eval_fair_share.holds_for_match``).
1764    """
1765
1766    free_slots: int = 0
1767    total_slots: int = 0
1768    reserve_pct: int = 0
1769    match_limit: int = 0
1770    match_pct: int = 0

The numbers a match-time wait compares, read now (#3466).

While a match is played on the shared nodes a new eval range waits for one of two limits, and the page names which and by how much:

free_slots: int = 0
total_slots: int = 0
reserve_pct: int = 0
match_limit: int = 0
match_pct: int = 0
class EvalQueueChange(ctfy.core.models.CtfyModel):
2303class EvalQueueChange(CtfyModel):
2304    """What a cancel / rerun did, by run id — one shape for a run, a
2305    campaign or a later batch, so a page can say what it pressed did.
2306
2307    ``cancelled`` moved to cancelled now (they were queued). ``stopping``
2308    were told to stop (``mode=now``) and turn cancelled within seconds.
2309    ``left_running`` were left to finish (``mode=drain``). ``grading`` were
2310    being graded and are never cancelled. ``queued`` are new rows appended
2311    (a requeue or a rerun).
2312    """
2313
2314    campaign_id: str = ""
2315    cancelled: list[str] = Field(default_factory=list)
2316    stopping: list[str] = Field(default_factory=list)
2317    left_running: list[str] = Field(default_factory=list)
2318    grading: list[str] = Field(default_factory=list)
2319    queued: list[str] = Field(default_factory=list)

What a cancel / rerun did, by run id — one shape for a run, a campaign or a later batch, so a page can say what it pressed did.

cancelled moved to cancelled now (they were queued). stopping were told to stop (mode=now) and turn cancelled within seconds. left_running were left to finish (mode=drain). grading were being graded and are never cancelled. queued are new rows appended (a requeue or a rerun).

campaign_id: str = ''
cancelled: list[str] = PydanticUndefined
stopping: list[str] = PydanticUndefined
left_running: list[str] = PydanticUndefined
grading: list[str] = PydanticUndefined
queued: list[str] = PydanticUndefined
class EvalRangeFailureReport(ctfy.core.models.CtfyModel):
2797class EvalRangeFailureReport(CtfyModel):
2798    """``range_failed`` runs grouped by challenge × node (#3233). Admin-only.
2799
2800    ``by_challenge`` spread over many nodes points at the challenge;
2801    ``by_node`` spread over many challenges points at the node.
2802    """
2803
2804    runs: int = 0
2805    attempts: int = 0
2806    #: Attempts whose instance has no archive or live row: counted under
2807    #: node ``""`` in the groups rather than dropped.
2808    no_record: int = 0
2809    groups: list[RangeFailureGroup] = Field(default_factory=list)
2810    by_challenge: list[RangeFailureRollup] = Field(default_factory=list)
2811    by_node: list[RangeFailureRollup] = Field(default_factory=list)

range_failed runs grouped by challenge × node (#3233). Admin-only.

by_challenge spread over many nodes points at the challenge; by_node spread over many challenges points at the node.

runs: int = 0
attempts: int = 0
no_record: int = 0
groups: list[ctfy.core.eval_range_failure.RangeFailureGroup] = PydanticUndefined
by_challenge: list[ctfy.core.eval_range_failure.RangeFailureRollup] = PydanticUndefined
by_node: list[ctfy.core.eval_range_failure.RangeFailureRollup] = PydanticUndefined
class EvalRunCoverage(ctfy.core.models.CtfyModel):
2814class EvalRunCoverage(CtfyModel):
2815    """How much of the range this run actually reached.
2816
2817    ``#1582``'s first question: *which planted vulnerabilities did the
2818    agent find, and which did it walk past*. The Reporting grade cannot
2819    answer it — it is graded against the team's own captures, so it
2820    measures whether what was written is true and says nothing about how
2821    much there was to write about.
2822
2823    ⚠️ **Two numbers, never one.** Coverage and fidelity move
2824    independently, and blending them is what made a run that found three
2825    of thirty-seven publish a 100. This model is the coverage half and
2826    carries no score at all.
2827
2828    ⚠️ **Per run, and that is the whole reason it is addressed by a run
2829    id.** An eval entrant's team is one synthetic team per
2830    ``(owner, harness, model)``, so a team-scoped reading pools every
2831    sample the agent has ever run — which is the second defect in the
2832    same issue.
2833    """
2834
2835    run_id: str = ""
2836    challenge_id: str = ""
2837    #: Every question the challenge declares, in the spec's own order.
2838    #: The **spec** decides the rows: a list derived from what was found
2839    #: has a success in every entry and cannot report a gap.
2840    rows: list[QuestionCoverageRow] = Field(default_factory=list)
2841    captured: int = 0
2842    missed: int = 0
2843    #: Questions no agent could have been credited for in this
2844    #: competition's format. ⚠️ A finding about the **range**, never about
2845    #: the model — counting these as missed reports the format's shape as
2846    #: the agent's failure.
2847    uncreditable: int = 0
2848    #: Whether the report was the only surface open, which is what makes
2849    #: ``uncreditable`` non-zero. Published so a reader is not left
2850    #: inferring the competition's mode from a count.
2851    report_only: bool = False
2852    #: Whether a Reporting grade was found for this run's team on this
2853    #: range. ⚠️ **Defaults ``False``**, and the pair with the count below
2854    #: is the point: ``found_not_described: 0`` means *nothing was walked
2855    #: past silently* only when this is ``True``. With no grade the same
2856    #: zero means *nobody has judged the document yet*, and the two send
2857    #: a reader to opposite places — ``ReportClaimsPage.read``'s rule, one
2858    #: feature over.
2859    graded: bool = False
2860    #: Questions this run captured that the graded report never mentions.
2861    #:
2862    #: ⚠️ **Not the grade's own ``not_mentioned``.** That one counts over
2863    #: ``build_fact_set``'s set, which is the team's *lifetime* captures
2864    #: on the range — and an eval entrant is one synthetic team per
2865    #: ``(owner, harness, model)``, so it pools every sample the agent
2866    #: ever ran. This is its restriction to what *this* run found, which
2867    #: is the only version that answers for a run.
2868    found_not_described: int = 0
2869    #: ⚠️ **Defaults ``False``, and the direction is deliberate.** An
2870    #: empty ``rows`` means either *the catalog no longer has this
2871    #: challenge* or *it declares nothing*, and those get opposite
2872    #: readings — the first makes the whole coverage unanswerable. The
2873    #: safe default is the one that refuses to publish ``0 of 0`` as a
2874    #: clean sheet.
2875    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 EvalRunEnvironment(ctfy.core.models.CtfyModel):
1601class EvalRunEnvironment(CtfyModel):
1602    """The range this run was measured against, read-only (#2675).
1603
1604    ⚠️ The owner cannot act on it (#2613): a status, a clock and a reason.
1605    """
1606
1607    #: ``""`` when the run named no range.
1608    instance_id: str = ""
1609    #: ``""`` when no live row holds it — not started yet, or reclaimed.
1610    status: ArchivedInstanceStatus = ""
1611    #: When the platform will reclaim it; ``0.0`` = no deadline known.
1612    expires_at_ts: float = 0.0
1613    #: The platform's player-safe failure summary, ``""`` when none.
1614    error: str = ""

The range this run was measured against, read-only (#2675).

⚠️ The owner cannot act on it (#2613): a status, a clock and a reason.

instance_id: str = ''
status: Union[ctfy.core.enums.InstanceStatus, Literal['']] = ''
expires_at_ts: float = 0.0
error: str = ''
class EvalRunEvent(ctfy.core.models.CtfyModel):
2270class EvalRunEvent(CtfyModel):
2271    """One event in an atomic evaluation task's life, oldest first."""
2272
2273    seq: int = 0
2274    at_ts: float = 0.0
2275    #: The stage the task was in once this event had happened.
2276    stage: AtomicStage = AtomicStage.QUEUED
2277    kind: AtomicEventKind = AtomicEventKind.QUEUED
2278    actor: AtomicActor = AtomicActor.PLATFORM
2279    #: A short, platform-written sentence (never agent-authored text).
2280    detail: str = ""

One event in an atomic evaluation task's life, oldest first.

seq: int = 0
at_ts: float = 0.0
stage: ctfy.core.eval_atomic.AtomicStage = <AtomicStage.QUEUED: 'queued'>
kind: ctfy.core.eval_atomic.AtomicEventKind = <AtomicEventKind.QUEUED: 'queued'>
actor: ctfy.core.eval_atomic.AtomicActor = <AtomicActor.PLATFORM: 'platform'>
detail: str = ''
class EvalRunEventList(ctfy.core.models.CtfyModel):
2283class EvalRunEventList(CtfyModel):
2284    """Every event of one run, in order (``GET /eval/runs/{id}/events``)."""
2285
2286    run_id: str = ""
2287    items: list[EvalRunEvent] = Field(default_factory=list)

Every event of one run, in order (GET /eval/runs/{id}/events).

run_id: str = ''
items: list[EvalRunEvent] = PydanticUndefined
class EvalRunFailure(ctfy.core.models.CtfyModel):
1617class EvalRunFailure(CtfyModel):
1618    """Why a finished run produced no result, for its run page (#2930).
1619
1620    ⚠️ ``code`` is translated (``common:error.<code>``) and ``error`` is the
1621    stored English sentence, shown when there is no code. ``error`` may
1622    quote the agent's own stderr, so it is the body readers' only — a
1623    team-mate gets the code and the exit code, never the text.
1624    """
1625
1626    code: RunFailureCode = ""
1627    error: str = ""
1628    #: The agent container's exit code; ``None`` when nobody observed one
1629    #: (a timeout, a cancel, a failure before the container started).
1630    exit_code: int | None = None
1631    #: Calls the gateway relayed for the run (``EvalRunState.relayed_calls``).
1632    relayed_calls: int = 0
1633    #: #3316: the stop reason the agent's own result gave (``no_target``),
1634    #: bounded to one line (``core.eval_run_failure.reported_stop_reason``);
1635    #: ``""`` when it gave none, or when it is the platform's ``exit_<n>``.
1636    #: Already public on the row as ``stop_reason``, so not body-readers only.
1637    reported_stop_reason: str = ""
1638    #: Where the agent's environment contract is published.
1639    protocol_url: str = AGENT_PROTOCOL_URL

Why a finished run produced no result, for its run page (#2930).

⚠️ code is translated (common:error.<code>) and error is the stored English sentence, shown when there is no code. error may quote the agent's own stderr, so it is the body readers' only — a team-mate gets the code and the exit code, never the text.

code: Literal['', 'eval_run_no_model_call', 'eval_run_no_model_credential', 'eval_run_no_model_unconfirmed', 'eval_run_no_model_no_target'] = ''
error: str = ''
exit_code: int | None = None
relayed_calls: int = 0
reported_stop_reason: str = ''
protocol_url: str = '/docs/standards/CTFY-0001'
class EvalRunLlmActivity(ctfy.core.models.CtfyModel):
1665class EvalRunLlmActivity(CtfyModel):
1666    """Whether the gateway is relaying a model request for this run now (#2676).
1667
1668    A fact the gateway holds (``llm_gateway/inflight.py``), read on every
1669    call drain. ⚠️ The row's ``llm_activity`` is ``None`` when the platform
1670    does not know — and then a page says nothing rather than "idle".
1671    """
1672
1673    in_flight: bool
1674    #: When the oldest open request arrived; ``0.0`` when none is open.
1675    since_ts: float = 0.0

Whether the gateway is relaying a model request for this run now (#2676).

A fact the gateway holds (llm_gateway/inflight.py), read on every call drain. ⚠️ The row's llm_activity is None when the platform does not know — and then a page says nothing rather than "idle".

in_flight: bool = PydanticUndefined
since_ts: float = 0.0
class EvalRunPanel(ctfy.core.models.CtfyModel):
1642class EvalRunPanel(CtfyModel):
1643    """The run page's side panel: questions, submissions, environment.
1644
1645    ⚠️ ``withheld`` is a silent competition with its report window open:
1646    the lists are empty because a verdict, its count and its timing are
1647    the result (``test_silent_mode_doors``), not because nothing happened.
1648    """
1649
1650    withheld: bool = False
1651    questions: list[EvalRunQuestion] = Field(default_factory=list)
1652    submissions: list[EvalRunSubmission] = Field(default_factory=list)
1653    #: More submissions fell in the window than the panel lists.
1654    submissions_truncated: bool = False
1655    environment: EvalRunEnvironment = Field(default_factory=EvalRunEnvironment)
1656    #: The end of the agent's stdout (#2550): at most 40 lines / 4000
1657    #: characters, its credentials and every answer the platform holds
1658    #: replaced *before* it was cut. ``""`` when nothing ran or printed, and
1659    #: for a team-mate — stdout is the author's, like the conversation.
1660    stdout_tail: str = ""
1661    #: ``not_yours`` when there is a tail and this reader may not see it.
1662    stdout_tail_hidden: Literal["", "not_yours"] = ""

The run page's side panel: questions, submissions, environment.

⚠️ withheld is a silent competition with its report window open: the lists are empty because a verdict, its count and its timing are the result (test_silent_mode_doors), not because nothing happened.

withheld: bool = False
questions: list[EvalRunQuestion] = PydanticUndefined
submissions: list[EvalRunSubmission] = PydanticUndefined
submissions_truncated: bool = False
environment: EvalRunEnvironment = PydanticUndefined
stdout_tail: str = ''
stdout_tail_hidden: Literal['', 'not_yours'] = ''
class EvalRunQuestion(ctfy.core.models.CtfyModel):
1565class EvalRunQuestion(CtfyModel):
1566    """One sub-question as this run met it (#2675), in the spec's order.
1567
1568    ⚠️ **A locked row carries its position and nothing else** — no id, no
1569    prompt: the id is an inventory and a gated prompt is shown only once
1570    revealed (``core/eval_run_panel.py``).
1571    """
1572
1573    #: 1-based place in the challenge's question list (the reveal order).
1574    position: int
1575    state: QuestionState
1576    #: The question's id; ``""`` while ``locked``.
1577    id: str = ""
1578    #: ``""`` while ``locked``.
1579    prompt: str = ""
1580    #: Wrong submissions to it inside this run's window.
1581    wrong_attempts: int = 0
1582    #: When this run first answered it; ``0.0`` while it has not.
1583    solved_at_ts: float = 0.0

One sub-question as this run met it (#2675), in the spec's order.

⚠️ A locked row carries its position and nothing else — no id, no prompt: the id is an inventory and a gated prompt is shown only once revealed (core/eval_run_panel.py).

position: int = PydanticUndefined
state: Literal['locked', 'open', 'solved'] = PydanticUndefined
id: str = ''
prompt: str = ''
wrong_attempts: int = 0
solved_at_ts: float = 0.0
class EvalRunRangeFailure(ctfy.core.models.CtfyModel):
2783class EvalRunRangeFailure(CtfyModel):
2784    """The ranges one run's attempts started, each joined to its record (#3233).
2785
2786    Admin-only: ``attempts[].error_detail`` is the node's own words, which
2787    the run's owner never reads (#2825).
2788    """
2789
2790    run_id: str
2791    challenge_id: str = ""
2792    status: str = ""
2793    unservable_reason: EvalUnservableReason = ""
2794    attempts: list[RangeAttempt] = Field(default_factory=list)

The ranges one run's attempts started, each joined to its record (#3233).

Admin-only: attempts[].error_detail is the node's own words, which the run's owner never reads (#2825).

run_id: str = PydanticUndefined
challenge_id: str = ''
status: str = ''
unservable_reason: Literal['', 'no_eligible_node', 'range_failed', 'access_unavailable'] = ''
attempts: list[ctfy.core.eval_range_failure.RangeAttempt] = PydanticUndefined
class EvalRunSubmission(ctfy.core.models.CtfyModel):
1586class EvalRunSubmission(CtfyModel):
1587    """One answer submitted inside this run's window (#2675)."""
1588
1589    id: str
1590    at_ts: float
1591    #: ``""`` when the question is one the panel shows as locked.
1592    question_id: str = ""
1593    correct: bool
1594    #: The submitted value — only for a wrong answer, to the run's owner,
1595    #: and never when it equals an answer the platform holds.
1596    value: str = ""
1597    #: Why ``value`` is empty; ``""`` when it is not withheld.
1598    hidden: HiddenValue = ""

One answer submitted inside this run's window (#2675).

id: str = PydanticUndefined
at_ts: float = PydanticUndefined
question_id: str = ''
correct: bool = PydanticUndefined
value: str = ''
hidden: Literal['', 'correct', 'not_yours', 'answer'] = ''
class EvalTrendPoint(ctfy.core.models.CtfyModel):
434class EvalTrendPoint(CtfyModel):
435    """Eval activity + run-level performance for one UTC day.
436
437    ``solve_rate`` here is *run-level* (fully-solved runs ÷ runs that day),
438    a different lens from the per-challenge best-of-k rate in the standings:
439    it tracks how the fleet is performing over time, not cumulative coverage.
440    """
441
442    period: str = ""  # UTC calendar day, YYYY-MM-DD
443    runs: int = 0
444    fully_solved: int = 0
445    solve_rate: float = 0.0
446    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 ExclusionConflictAccount(ctfy.core.models.CtfyModel):
159class ExclusionConflictAccount(CtfyModel):
160    """An account holding seats in two (or more) members of one group."""
161
162    user_id: str
163    display_name: str = ""
164    seats: list[ExclusionConflictSeat] = Field(default_factory=list)

An account holding seats in two (or more) members of one group.

user_id: str = PydanticUndefined
display_name: str = ''
seats: list[ExclusionConflictSeat] = PydanticUndefined
class ExclusionConflictInfo(ctfy.core.models.CtfyModel):
586class ExclusionConflictInfo(CtfyModel):
587    """「互斥冲突」 (#2882): members of this team also registered in a competition
588    that excludes this one, from before the exclusion group existed.
589
590    ``competition`` is ``None`` when the reader may not open it (they are told
591    "another competition that excludes this one"); ``other_competition_id`` is
592    never sent for that reason. ``resolved_at`` is when an approval recorded
593    that the reviewer saw it, ``None`` while it is open.
594    """
595
596    competition: ExclusionPeer | None = None
597    members: list[ExclusionConflictMember] = Field(default_factory=list)
598    detected_at: datetime
599    resolved_at: datetime | None = None
600    resolved_by_name: str = ""

「互斥冲突」 (#2882): members of this team also registered in a competition that excludes this one, from before the exclusion group existed.

competition is None when the reader may not open it (they are told "another competition that excludes this one"); other_competition_id is never sent for that reason. resolved_at is when an approval recorded that the reviewer saw it, None while it is open.

competition: ExclusionPeer | None = None
members: list[ExclusionConflictMember] = PydanticUndefined
detected_at: datetime.datetime = PydanticUndefined
resolved_at: datetime.datetime | None = None
resolved_by_name: str = ''
class ExclusionConflictMember(ctfy.core.models.CtfyModel):
579class ExclusionConflictMember(CtfyModel):
580    """A member of this team who is also on a team in the other competition."""
581
582    user_id: str
583    display_name: str = ""

A member of this team who is also on a team in the other competition.

user_id: str = PydanticUndefined
display_name: str = ''
class ExclusionConflictPerson(ctfy.core.models.CtfyModel):
133class ExclusionConflictPerson(CtfyModel):
134    """An account, by its chosen name (``""`` when it has none)."""
135
136    user_id: str
137    display_name: str = ""

An account, by its chosen name ("" when it has none).

user_id: str = PydanticUndefined
display_name: str = ''
class ExclusionConflictReport(ctfy.core.models.CtfyModel):
177class ExclusionConflictReport(CtfyModel):
178    """Every group with a live conflict (#2931). ``account_count`` and
179    ``team_count`` are distinct across groups -- an account crossing in two
180    groups is one account. ``deadline`` is the configured
181    ``exclusion_conflict_deadline`` (``None``: unset)."""
182
183    groups: list[ExclusionGroupConflicts] = Field(default_factory=list)
184    account_count: int = 0
185    team_count: int = 0
186    deadline: AwareDatetime | None = None

Every group with a live conflict (#2931). account_count and team_count are distinct across groups -- an account crossing in two groups is one account. deadline is the configured exclusion_conflict_deadline (None: unset).

groups: list[ExclusionGroupConflicts] = PydanticUndefined
account_count: int = 0
team_count: int = 0
deadline: pydantic.types.AwareDatetime | None = None
class ExclusionConflictSeat(ctfy.core.models.CtfyModel):
140class ExclusionConflictSeat(CtfyModel):
141    """One of the seats a conflicting account holds in this group: the
142    competition, the team and its teammates, and where its review stands.
143
144    ``flagged`` is whether the #2882 sweep has an open 「互斥冲突」 row for
145    the team; ``notified_channels`` the channels on which its members have
146    had the #2931 notice for every crossing of this team (#2943), and
147    ``notified`` whether that is any channel at all."""
148
149    competition: ExclusionPeer
150    team_id: str
151    team_name: str = ""
152    review_status: RegistrationReviewStatus = "approved"
153    flagged: bool = False
154    notified: bool = False
155    notified_channels: list[ExclusionNoticeChannel] = Field(default_factory=list)
156    members: list[ExclusionConflictPerson] = Field(default_factory=list)

One of the seats a conflicting account holds in this group: the competition, the team and its teammates, and where its review stands.

flagged is whether the #2882 sweep has an open 「互斥冲突」 row for the team; notified_channels the channels on which its members have had the #2931 notice for every crossing of this team (#2943), and notified whether that is any channel at all.

competition: ExclusionPeer = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = ''
review_status: Literal['pending', 'approved', 'rejected'] = 'approved'
flagged: bool = False
notified: bool = False
notified_channels: list[typing.Literal['inbox', 'email']] = PydanticUndefined
members: list[ExclusionConflictPerson] = PydanticUndefined
class ExclusionGroupConflicts(ctfy.core.models.CtfyModel):
167class ExclusionGroupConflicts(CtfyModel):
168    """One group's conflicts. Decided per group: an account holding seats in
169    two competitions that share no group is not here."""
170
171    group_id: str
172    group_name: str = ""
173    accounts: list[ExclusionConflictAccount] = Field(default_factory=list)
174    team_count: int = 0

One group's conflicts. Decided per group: an account holding seats in two competitions that share no group is not here.

group_id: str = PydanticUndefined
group_name: str = ''
accounts: list[ExclusionConflictAccount] = PydanticUndefined
team_count: int = 0
class ExclusionGroupCreate(ctfy.core.models.CtfyModel):
63class ExclusionGroupCreate(CtfyModel):
64    """Create a group."""
65
66    name: str = Field(min_length=1, max_length=120)
67    switch_after_start: bool = False
68    competition_ids: list[str] = Field(default_factory=list)

Create a group.

name: str = PydanticUndefined
switch_after_start: bool = False
competition_ids: list[str] = PydanticUndefined
class ExclusionGroupInfo(ctfy.core.models.CtfyModel):
34class ExclusionGroupInfo(CtfyModel):
35    """One group, as the platform administrator's page reads it.
36
37    ``competitions`` is every member (an administrator can open every
38    competition). ``competition_ids`` is the same set as bare ids, which is
39    what an edit sends back.
40    """
41
42    id: str = ""
43    name: str = ""
44    switch_after_start: bool = False
45    created_at: AwareDatetime | None = None
46    created_by: str = ""
47    competition_ids: list[str] = Field(default_factory=list)
48    competitions: list[ExclusionPeer] = Field(default_factory=list)
49    #: Accounts holding seats in two members of **this** group right now
50    #: (#2931). Advisory: a save that leaves some is not refused; the page
51    #: links to the conflict list instead.
52    conflict_accounts: int = 0

One group, as the platform administrator's page reads it.

competitions is every member (an administrator can open every competition). competition_ids is the same set as bare ids, which is what an edit sends back.

id: str = ''
name: str = ''
switch_after_start: bool = False
created_at: pydantic.types.AwareDatetime | None = None
created_by: str = ''
competition_ids: list[str] = PydanticUndefined
competitions: list[ExclusionPeer] = PydanticUndefined
conflict_accounts: int = 0
class ExclusionGroupPage(ctfy.core.models.CtfyModel):
55class ExclusionGroupPage(CtfyModel):
56    """Every group. Unwindowed -- a short catalogue an operator curates -- so
57    ``total`` is the heading's count, not an answer to "was this cut"."""
58
59    items: list[ExclusionGroupInfo] = Field(default_factory=list)
60    total: int = 0

Every group. Unwindowed -- a short catalogue an operator curates -- so total is the heading's count, not an answer to "was this cut".

items: list[ExclusionGroupInfo] = PydanticUndefined
total: int = 0
class ExclusionGroupUpdate(ctfy.core.models.CtfyModel):
71class ExclusionGroupUpdate(CtfyModel):
72    """Edit a group. Partial: a field left out is left alone, so editing the
73    name never blanks the members (``model_fields_set``)."""
74
75    name: str | None = Field(default=None, min_length=1, max_length=120)
76    switch_after_start: bool | None = None
77    competition_ids: list[str] | None = None

Edit a group. Partial: a field left out is left alone, so editing the name never blanks the members (model_fields_set).

name: str | None = None
switch_after_start: bool | None = None
competition_ids: list[str] | None = None
ExclusionNoticeChannel = typing.Literal['inbox', 'email']
class ExclusionNoticeItem(ctfy.core.models.CtfyModel):
215class ExclusionNoticeItem(CtfyModel):
216    """One team crossing: this team, in this competition, against that one.
217
218    ``channels`` are the requested channels queued now (or that would be);
219    ``already`` those this crossing was told on before. ``outcome`` is
220    ``already_sent`` only when every requested channel is in ``already``."""
221
222    team_id: str
223    team_name: str = ""
224    competition: ExclusionPeer
225    other_competition: ExclusionPeer
226    recipients: int = 0
227    outcome: ExclusionNoticeOutcome
228    channels: list[ExclusionNoticeChannel] = Field(default_factory=list)
229    already: list[ExclusionNoticeChannel] = Field(default_factory=list)

One team crossing: this team, in this competition, against that one.

channels are the requested channels queued now (or that would be); already those this crossing was told on before. outcome is already_sent only when every requested channel is in already.

team_id: str = PydanticUndefined
team_name: str = ''
competition: ExclusionPeer = PydanticUndefined
other_competition: ExclusionPeer = PydanticUndefined
recipients: int = 0
outcome: Literal['sent', 'would_send', 'already_sent', 'event_started'] = PydanticUndefined
channels: list[typing.Literal['inbox', 'email']] = PydanticUndefined
already: list[typing.Literal['inbox', 'email']] = PydanticUndefined
ExclusionNoticeOutcome = typing.Literal['sent', 'would_send', 'already_sent', 'event_started']
class ExclusionNoticeRequest(ctfy.core.models.CtfyModel):
189class ExclusionNoticeRequest(CtfyModel):
190    """Tell the members of every conflicting team to withdraw from one event.
191
192    ``deadline`` defaults to the ``exclusion_conflict_deadline`` setting;
193    sending with neither is a 422, a dry run without one previews (#2943).
194    ``dry_run`` defaults to **true**: the operator reads what would be sent
195    first. ``team_ids`` narrows to the teams a dry run listed (``None``: every
196    conflicting team). ``channels`` defaults to both; an empty list is a 422."""
197
198    deadline: AwareDatetime | None = None
199    group_id: str = ""
200    team_ids: list[str] | None = None
201    dry_run: bool = True
202    channels: list[ExclusionNoticeChannel] = Field(
203        default_factory=lambda: list(EXCLUSION_NOTICE_CHANNELS)
204    )

Tell the members of every conflicting team to withdraw from one event.

deadline defaults to the exclusion_conflict_deadline setting; sending with neither is a 422, a dry run without one previews (#2943). dry_run defaults to true: the operator reads what would be sent first. team_ids narrows to the teams a dry run listed (None: every conflicting team). channels defaults to both; an empty list is a 422.

deadline: pydantic.types.AwareDatetime | None = None
group_id: str = ''
team_ids: list[str] | None = None
dry_run: bool = True
channels: list[typing.Literal['inbox', 'email']] = PydanticUndefined
class ExclusionNoticeResult(ctfy.core.models.CtfyModel):
232class ExclusionNoticeResult(CtfyModel):
233    """What the notice action did, or would do. ``deadline`` is ``None`` only
234    on a dry run asked without one (#2943) -- sending always names it."""
235
236    dry_run: bool
237    deadline: AwareDatetime | None = None
238    channels: list[ExclusionNoticeChannel] = Field(default_factory=list)
239    items: list[ExclusionNoticeItem] = Field(default_factory=list)
240    #: Team crossings queued (or that would be).
241    sent: int = 0
242    #: People those reach.
243    recipients: int = 0

What the notice action did, or would do. deadline is None only on a dry run asked without one (#2943) -- sending always names it.

dry_run: bool = PydanticUndefined
deadline: pydantic.types.AwareDatetime | None = None
channels: list[typing.Literal['inbox', 'email']] = PydanticUndefined
items: list[ExclusionNoticeItem] = PydanticUndefined
sent: int = 0
recipients: int = 0
class ExclusionPeer(ctfy.core.models.CtfyModel):
21class ExclusionPeer(CtfyModel):
22    """A competition named inside a group, as its reader may see it.
23
24    ``title_i18n`` crosses as a map and is resolved in the browser, like every
25    other competition title. A competition the reader cannot open is never
26    one of these -- see :class:`CompetitionExclusion`'s ``hidden``.
27    """
28
29    id: str = ""
30    title: str = ""
31    title_i18n: dict[str, str] = Field(default_factory=dict)

A competition named inside a group, as its reader may see it.

title_i18n crosses as a map and is resolved in the browser, like every other competition title. A competition the reader cannot open is never one of these -- see CompetitionExclusion's hidden.

id: str = ''
title: str = ''
title_i18n: dict[str, str] = PydanticUndefined
FeaturedStatus = typing.Literal['registration_open', 'upcoming', 'running']
class FeaturedTrack(ctfy.core.models.CtfyModel):
25class FeaturedTrack(CtfyModel):
26    """One competition on the featured card — one track of the event.
27
28    Everything here is already on the public competition page; this is a
29    narrower projection of it, so it publishes nothing new.
30    """
31
32    #: The competition's own id — a track *is* a competition.
33    id: str = ""
34    title: str = ""
35    #: The title per locale when this row is a competition (#2230); a
36    #: browser renders this, ``title`` is its base-locale reading.
37    title_i18n: LocalizedText = Field(default_factory=dict)
38    description: LocalizedText = Field(default_factory=dict)
39    #: The roster cap in force (the competition's own, else the
40    #: platform's); ``0`` is unlimited. ``1`` is a solo track.
41    max_team_size: int = 0
42    starts_at: AwareDatetime | None = None
43    ends_at: AwareDatetime | None = None
44    sessions: list[CompetitionSessionInfo] = Field(default_factory=list)
45    registration_ends_at: AwareDatetime | None = None
46    status: FeaturedStatus = "upcoming"
47    #: The next instant play opens on this track, or ``None`` when it is
48    #: already running with no later session.
49    next_start_at: AwareDatetime | None = None

One competition on the featured card — one track of the event.

Everything here is already on the public competition page; this is a narrower projection of it, so it publishes nothing new.

id: str = ''
title: str = ''
title_i18n: dict[str, str] = PydanticUndefined
description: dict[str, str] = PydanticUndefined
max_team_size: int = 0
starts_at: pydantic.types.AwareDatetime | None = None
ends_at: pydantic.types.AwareDatetime | None = None
sessions: list[CompetitionSessionInfo] = PydanticUndefined
registration_ends_at: pydantic.types.AwareDatetime | None = None
status: Literal['registration_open', 'upcoming', 'running'] = 'upcoming'
next_start_at: pydantic.types.AwareDatetime | None = None
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 FirstBloodMove(ctfy.core.models.CtfyModel):
60class FirstBloodMove(CtfyModel):
61    """First blood of one question changes hands.
62
63    ``to_team_id`` of ``""`` means nobody holds it afterwards -- the voided
64    team was the only solver.
65    """
66
67    challenge_id: str
68    challenge_name: str = ""
69    flag_id: str
70    from_team_id: str
71    from_team_name: str = ""
72    to_team_id: str = ""
73    to_team_name: str = ""

First blood of one question changes hands.

to_team_id of "" means nobody holds it afterwards -- the voided team was the only solver.

challenge_id: str = PydanticUndefined
challenge_name: str = ''
flag_id: str = PydanticUndefined
from_team_id: str = PydanticUndefined
from_team_name: str = ''
to_team_id: str = ''
to_team_name: str = ''
class FleetReachAck(ctfy.core.models.CtfyModel):
471class FleetReachAck(CtfyModel):
472    """``POST /admin/fleet-reach/ack`` (#3428): the cb renderer applied this.
473
474    Sent only after an ``apply`` that left nothing to do. ``entries`` is the
475    manifest it applied, verbatim; the platform recomputes ``digest`` from it
476    and refuses a mismatch. ``pools`` are the agent pools whose every node
477    sits behind this renderer's firewall.
478    """
479
480    pools: list[NodePool] = Field(min_length=1, max_length=8)
481    digest: str = Field(min_length=64, max_length=64)
482    entries: list[FleetReachEntry] = Field(max_length=4096)

POST /admin/fleet-reach/ack (#3428): the cb renderer applied this.

Sent only after an apply that left nothing to do. entries is the manifest it applied, verbatim; the platform recomputes digest from it and refuses a mismatch. pools are the agent pools whose every node sits behind this renderer's firewall.

pools: list[typing.Literal['ordinary', 'authored', 'quarantine', 'freeze', 'dedicated', 'eval', 'attacker']] = PydanticUndefined
digest: str = PydanticUndefined
entries: list[FleetReachEntry] = PydanticUndefined
class FleetReachAckCleared(ctfy.core.models.CtfyModel):
493class FleetReachAckCleared(CtfyModel):
494    """``DELETE /admin/fleet-reach/ack`` (#3428): the pools whose ack was removed.
495
496    Each now reads as never acked (unproven, fails open). Empty when there
497    was nothing to clear. ``str``, not ``NodePool``: a rollback lever must
498    still clear (and name) a row nobody can type-check any more.
499    """
500
501    pools: list[str]

DELETE /admin/fleet-reach/ack (#3428): the pools whose ack was removed.

Each now reads as never acked (unproven, fails open). Empty when there was nothing to clear. str, not NodePool: a rollback lever must still clear (and name) a row nobody can type-check any more.

pools: list[str] = PydanticUndefined
class FleetReachAckResult(ctfy.core.models.CtfyModel):
485class FleetReachAckResult(CtfyModel):
486    """What an ack recorded: per pool, the nodes now derived reachable."""
487
488    pools: list[NodePool]
489    digest: str
490    node_ids: list[str]

What an ack recorded: per pool, the nodes now derived reachable.

pools: list[typing.Literal['ordinary', 'authored', 'quarantine', 'freeze', 'dedicated', 'eval', 'attacker']] = PydanticUndefined
digest: str = PydanticUndefined
node_ids: list[str] = PydanticUndefined
class FleetReachEntry(ctfy.core.models.CtfyModel):
440class FleetReachEntry(CtfyModel):
441    """One accept an agent on another node needs (#3428, ``core.fleet_reach``)."""
442
443    node_id: str
444    node: str
445    purpose: ReachPurpose
446    host: str
447    proto: Literal["tcp", "udp"]
448    #: ``"443"``, ``"80,443"`` or ``"40000-40099"``.
449    ports: str

One accept an agent on another node needs (#3428, core.fleet_reach).

node_id: str = PydanticUndefined
node: str = PydanticUndefined
purpose: ctfy.core.fleet_reach.ReachPurpose = PydanticUndefined
host: str = PydanticUndefined
proto: Literal['tcp', 'udp'] = PydanticUndefined
ports: str = PydanticUndefined
class FleetReachGap(ctfy.core.models.CtfyModel):
452class FleetReachGap(CtfyModel):
453    """A purpose a node may need and did not report enough to derive."""
454
455    node_id: str
456    node: str
457    purpose: ReachPurpose
458    why: str

A purpose a node may need and did not report enough to derive.

node_id: str = PydanticUndefined
node: str = PydanticUndefined
purpose: ctfy.core.fleet_reach.ReachPurpose = PydanticUndefined
why: str = PydanticUndefined
class FleetReachManifest(ctfy.core.models.CtfyModel):
461class FleetReachManifest(CtfyModel):
462    """``GET /admin/fleet-reach`` (#3428): derived from the node rows, never typed."""
463
464    version: int
465    #: SHA-256 of the entries, order-free: what the cb renderer acks (#3428).
466    digest: str
467    entries: list[FleetReachEntry]
468    gaps: list[FleetReachGap]

GET /admin/fleet-reach (#3428): derived from the node rows, never typed.

version: int = PydanticUndefined
digest: str = PydanticUndefined
entries: list[FleetReachEntry] = PydanticUndefined
gaps: list[FleetReachGap] = PydanticUndefined
class FleetReachToken(ctfy.core.models.CtfyModel):
504class FleetReachToken(CtfyModel):
505    """``POST /admin/fleet-reach/token`` (#3428): the cb renderer's bearer.
506
507    ``token`` is shown on this response only; the platform keeps its digest.
508    ``routes`` is the bearer's whole scope, ``"<METHOD> <path>"``.
509    """
510
511    token: str
512    routes: list[str]

POST /admin/fleet-reach/token (#3428): the cb renderer's bearer.

token is shown on this response only; the platform keeps its digest. routes is the bearer's whole scope, "<METHOD> <path>".

token: str = PydanticUndefined
routes: list[str] = 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):
3011class GrantAuthoringReviewerRequest(CtfyModel):
3012    """Body for ``POST /admin/competitions/{id}/authoring-reviewers`` —
3013    the target by id or (case-insensitive) email, the competition-admin
3014    grant's rule. No ``role``: this grant has exactly one."""
3015
3016    user_id: str = ""
3017    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):
3020class GrantCompetitionAdminRequest(CtfyModel):
3021    """Body for ``PUT /admin/competitions/{id}/admins`` — resolve the
3022    target user by id or (case-insensitive) email.
3023
3024    ``role`` picks the grant tier: ``admin`` (full organiser control of
3025    this event) or ``reviewer`` (roster read + eligibility verdicts +
3026    export, nothing that removes a team). Defaults to ``admin`` so an
3027    older client that omits it keeps the prior behaviour.
3028
3029    ``reason`` is an optional organiser note carried onto the invitation
3030    row (and, for a fresh invitation, into the mail). Only the
3031    participation-invite routes read it; the admin-grant routes that
3032    share this body ignore it."""
3033
3034    user_id: str = ""
3035    email: str = ""
3036    role: CompetitionGrantRole = "admin"
3037    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 GroupRow(ctfy.core.models.CtfyModel):
118class GroupRow(CtfyModel):
119    group_no: int
120    teams: list[GroupTeamRow] = 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.

group_no: int = PydanticUndefined
teams: list[GroupTeamRow] = PydanticUndefined
class GroupTeamRow(ctfy.core.models.CtfyModel):
110class GroupTeamRow(CtfyModel):
111    team_id: str
112    team_name: str = ""
113    #: The team's value of the field the draw spread by (the colour of its
114    #: dot); ``""`` when the draw spread by nothing.
115    value: 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.

team_id: str = PydanticUndefined
team_name: str = ''
value: str = ''
class GroupingDraw(ctfy.core.models.CtfyModel):
131class GroupingDraw(CtfyModel):
132    """``POST``: (re)divide the teams.
133
134    ``csv`` is ``team id, group number`` lines, read when ``method`` is
135    ``csv``. ``seed`` ``None`` picks a fresh one. After a group has a running
136    or stored environment a re-draw is refused unless ``force``, which stops
137    those boxes and discards their flags.
138    """
139
140    method: GroupingMethod = GroupingMethod.RANDOM
141    by_field: str = ""
142    seed: int | None = None
143    csv: str = ""
144    force: bool = False

POST: (re)divide the teams.

csv is team id, group number lines, read when method is csv. seed None picks a fresh one. After a group has a running or stored environment a re-draw is refused unless force, which stops those boxes and discards their flags.

method: ctfy.core.enums.GroupingMethod = <GroupingMethod.RANDOM: 'random'>
by_field: str = ''
seed: int | None = None
csv: str = ''
force: bool = False
class GroupingTable(ctfy.core.models.CtfyModel):
123class GroupingTable(CtfyModel):
124    """Who is in which group. ⚠️ Organiser-only: this *is* the secret."""
125
126    competition_id: str
127    by_field: str = ""
128    groups: list[GroupRow] = Field(default_factory=list)

Who is in which group. ⚠️ Organiser-only: this is the secret.

competition_id: str = PydanticUndefined
by_field: str = ''
groups: list[GroupRow] = PydanticUndefined
class HarnessAdmission(ctfy.core.models.CtfyModel):
482class HarnessAdmission(CtfyModel):
483    """Whether this agent's current image may run — the only gate (#2356, #2614).
484
485    ⚠️ **Composed server-side from the one decision**
486    (`core.harness_admission.admission_of`): a client recomputing it would
487    eventually disagree with the gate, silently.
488
489    ``admitted`` — the check and the hardened probe passed on ``digest``;
490    saving a release (which every entry does) pins it. ``not_admitted`` —
491    ``reason`` names the one fact to fix. There is no review and no switch.
492    """
493
494    state: Literal["admitted", "not_admitted"] = "not_admitted"
495    reason: AdmissionReason = ""
496    digest: str = ""

Whether this agent's current image may run — the only gate (#2356, #2614).

⚠️ Composed server-side from the one decision (core.harness_admission.admission_of): a client recomputing it would eventually disagree with the gate, silently.

admitted — the check and the hardened probe passed on digest; saving a release (which every entry does) pins it. not_admitted — reason names the one fact to fix. There is no review and no switch.

state: Literal['admitted', 'not_admitted'] = 'not_admitted'
reason: Literal['', 'harness_inactive', 'harness_check_missing', 'harness_check_failed', 'harness_probe_missing', 'harness_probe_failed', 'harness_probe_stale', 'harness_probe_not_hardened', 'harness_image_runs_as_root', 'harness_image_size_unknown', 'harness_image_too_large', 'harness_storage_quota_exceeded'] = ''
digest: str = ''
class HarnessCreate(ctfy.core.models.CtfyModel):
551class HarnessCreate(CtfyModel):
552    """What a user may say when registering an agent: an **allow-list** (#2608).
553
554    ⚠️ **Not ``HarnessState``.** Registration took the state model and then
555    overwrote the fields it would not read from the request — a strip-list,
556    and every column added since (the conformance reading, the probe's
557    evidence, the image facts admission reads) had to be remembered there
558    or a client could forge it. Here a server-owned field is simply not
559    representable; the route builds the row from these and nothing else.
560
561    ⚠️ ``test_every_harness_field_has_an_author.py`` fails a ``HarnessState``
562    field that is neither listed here nor in :data:`HARNESS_SERVER_OWNED`.
563    Unknown keys are ignored, so an old client sending a whole row still
564    registers — with every verdict it sent dropped.
565    """
566
567    #: The *name*; the stored id is ``<owner_user_id>/<name>``.
568    id: str = ""
569    image: str = ""
570    display_name: str = ""
571    version: str = ""
572    notes: str = ""
573    source_url: WebUrl = ""
574    active: bool = True
575    #: #3473: the wire surface the agent calls; ``""`` is not declared.
576    dialect: AgentDialect = ""

What a user may say when registering an agent: an allow-list (#2608).

⚠️ Not HarnessState. Registration took the state model and then overwrote the fields it would not read from the request — a strip-list, and every column added since (the conformance reading, the probe's evidence, the image facts admission reads) had to be remembered there or a client could forge it. Here a server-owned field is simply not representable; the route builds the row from these and nothing else.

⚠️ test_every_harness_field_has_an_author.py fails a HarnessState field that is neither listed here nor in HARNESS_SERVER_OWNED. Unknown keys are ignored, so an old client sending a whole row still registers — with every verdict it sent dropped.

id: str = ''
image: str = ''
display_name: str = ''
version: str = ''
notes: str = ''
source_url: Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x74f512354e00>)] = ''
active: bool = True
dialect: Union[Literal['anthropic_messages', 'openai_chat', 'openai_responses'], Literal['']] = ''
class HarnessExploit(ctfy.core.models.CtfyModel):
2201class HarnessExploit(CtfyModel):
2202    """The reference exploit, as a fenced harness run carries it (#2819 W2).
2203
2204    The harness run's own fence is the network (the held bridge, or the
2205    player's tunnel); this is only what runs inside it: the tree, the target
2206    a player is handed, and the build limits of ``solution/Dockerfile``.
2207    """
2208
2209    #: ``{tree path: base64(bytes)}`` — the version's tree, the instance's
2210    #: rendered attachments over it. ``repr=False``: it may carry answers.
2211    tree: dict[str, str] = Field(default_factory=dict, repr=False)
2212    executables: list[str] = Field(default_factory=list)
2213    host: str
2214    port: int
2215    #: ``CTFY_SERVICE_URL_<SVC>`` → the player's URL of each sibling service.
2216    service_urls: dict[str, str] = Field(default_factory=dict)
2217    #: The limits ``solution/Dockerfile`` builds within; ``None`` refuses a
2218    #: tree that ships one (the authored rule, #2504).
2219    build: ResourceBudget | None = None

The reference exploit, as a fenced harness run carries it (#2819 W2).

The harness run's own fence is the network (the held bridge, or the player's tunnel); this is only what runs inside it: the tree, the target a player is handed, and the build limits of solution/Dockerfile.

tree: dict[str, str] = PydanticUndefined
executables: list[str] = PydanticUndefined
host: str = PydanticUndefined
port: int = PydanticUndefined
service_urls: dict[str, str] = PydanticUndefined
build: ctfy.core.resource_budget.ResourceBudget | None = None
class HarnessModelCell(ctfy.core.models.CtfyModel):
404class HarnessModelCell(CtfyModel):
405    """One ``(model, harness)`` pair's solve stats — a cross-tab cell.
406
407    The apples-to-apples lens the pooled rollups can't give: holding the
408    model fixed and varying the harness isolates the harness's effect on
409    that model (e.g. ``gpt-5`` solves 80% under one harness, 60% under
410    another). Display labels are resolved frontend-side from the model /
411    harness standings, so the cell carries only ids + stats.
412    """
413
414    model_id: str = ""
415    #: harness registry id; blank = the built-in ctfy harness.
416    harness_id: str = ""
417    challenges_attempted: int = 0
418    challenges_solved: int = 0
419    solve_rate: float = 0.0
420    #: ⚠️ **#2614: the rank and the headline are the mean over campaigns**
421    #: (``core.eval_board_mean``), one basis for every table on the page.
422    #: ``challenges_solved`` / ``solve_rate`` above pool every sample
423    #: (best-of) and stay only as coverage; nothing ranks on or shows them.
424    #: Standalone runs (no campaign) are in no mean. ``campaigns == 0`` is
425    #: unmeasured, never a zero.
426    campaigns: int = 0
427    mean_solved: float = 0.0
428    mean_attempted: float = 0.0
429    mean_solve_rate: float = 0.0
430    #: Each campaign's solve rate, oldest first: the spread, kept visible.
431    campaign_solve_rates: list[float] = Field(default_factory=list)

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
campaigns: int = 0
mean_solved: float = 0.0
mean_attempted: float = 0.0
mean_solve_rate: float = 0.0
campaign_solve_rates: list[float] = PydanticUndefined
class HarnessRegistryCredentialPut(ctfy.core.models.CtfyModel):
535class HarnessRegistryCredentialPut(CtfyModel):
536    """A login to the registry an agent's image lives on (#3475).
537
538    ⚠️ **No ``registry`` field**: the host is the agent's image's, read by
539    the platform, so a login can never be pointed at another host by editing
540    one field. ``secret`` is write-only — no response carries it.
541    """
542
543    username: str = ""
544    #: ⚠️ No length or shape constraint *here*: a validation 422 echoes the
545    #: offending ``input`` back in its issues, so the route checks the bounds
546    #: itself (``harness_registry_credential_invalid``) and says only which
547    #: field is wrong.
548    secret: str = Field(default="", repr=False, json_schema_extra={"secret": True})

A login to the registry an agent's image lives on (#3475).

⚠️ No registry field: the host is the agent's image's, read by the platform, so a login can never be pointed at another host by editing one field. secret is write-only — no response carries it.

username: str = ''
secret: str = ''
class HarnessRegistryCredentialView(ctfy.core.models.CtfyModel):
516class HarnessRegistryCredentialView(CtfyModel):
517    """That an agent holds a registry login (#3475). ⚠️ Never the secret.
518
519    ``registry`` is the image's host, derived by the platform; ``set_at_ts``
520    is when the owner stored it, so they can tell an old one from a new one.
521    """
522
523    registry: str = ""
524    username: str = ""
525    set_at_ts: float = 0.0

That an agent holds a registry login (#3475). ⚠️ Never the secret.

registry is the image's host, derived by the platform; set_at_ts is when the owner stored it, so they can tell an old one from a new one.

registry: str = ''
username: str = ''
set_at_ts: float = 0.0
class HarnessStanding(ctfy.core.models.CtfyModel):
223class HarnessStanding(CtfyModel):
224    """One harness's aggregate scorecard across all the models it drove.
225
226    The harness's runs are pooled across models: a challenge counts as
227    *solved* if **any** run under the harness solved it. Like the vendor
228    rollup this ranks harness *plus its model fleet*, not the harness in
229    isolation — a harness paired with stronger models scores higher.
230    """
231
232    rank: int = 0
233    #: Which metric put this row below the one above it (#2341, L8):
234    #: ``None`` on the first row; ``solved`` when the counts differ, and a
235    #: secondary metric when they do not. Ranks are never shared.
236    decided_by: RankMetric | None = None
237    #: harness registry id; blank = the built-in ctfy harness.
238    harness_id: str = ""
239    display_name: str = ""
240    #: distinct models evaluated under this harness.
241    models: int = 0
242    #: The models this row merges, by id, busiest first (#2341, L10): the
243    #: by-harness view names them, since a merged row is no single pair.
244    model_ids: list[str] = Field(default_factory=list)
245    runs: int = 0
246    challenges_errored: int = 0
247    challenges_attempted: int = 0
248    challenges_solved: int = 0
249    solve_rate: float = 0.0
250    questions_solved: int = 0
251    total_tokens: int = 0
252    #: estimated USD cost summed across the harness's runs (0 when unpriced).
253    cost_usd: float = 0.0
254    #: The L8 secondary metrics (#2341). ⚠️ ``None`` is unknown, never
255    #: zero: nothing solved, or the runs reported no usage / no turns. A
256    #: ``0`` here would rank an unreported row as the cheapest one.
257    tokens_per_solve: float | None = None
258    avg_turns: float | None = None
259    last_run_at: AwareDatetime | None = None
260    low_sample: bool = False
261    #: Whose agent this is: author, version, source, digest.
262    #:
263    #: ⚠️ ``None`` for the platform's own harnesses — they have no owner,
264    #: so there is no author to link to. This board is *entirely* agents,
265    #: which is what makes the attribution matter more here than on a
266    #: mixed board: a comparison table whose subject is a slug compares
267    #: nothing a reader can go and check.
268    agent: AgentEntrantInfo | None = None
269    #: ⚠️ **#2614: the rank and the headline are the mean over campaigns**
270    #: (``core.eval_board_mean``), one basis for every table on the page.
271    #: ``challenges_solved`` / ``solve_rate`` above pool every sample
272    #: (best-of) and stay only as coverage; nothing ranks on or shows them.
273    #: Standalone runs (no campaign) are in no mean. ``campaigns == 0`` is
274    #: unmeasured, never a zero.
275    campaigns: int = 0
276    mean_solved: float = 0.0
277    mean_attempted: float = 0.0
278    mean_solve_rate: float = 0.0
279    #: Each campaign's solve rate, oldest first: the spread, kept visible.
280    campaign_solve_rates: list[float] = Field(default_factory=list)

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
decided_by: Optional[Literal['solved', 'solve_rate', 'tokens_per_solve', 'avg_turns', 'last_run_at', 'id']] = None
harness_id: str = ''
display_name: str = ''
models: int = 0
model_ids: list[str] = PydanticUndefined
runs: int = 0
challenges_errored: 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
tokens_per_solve: float | None = None
avg_turns: float | None = None
last_run_at: pydantic.types.AwareDatetime | None = None
low_sample: bool = False
agent: AgentEntrantInfo | None = None
campaigns: int = 0
mean_solved: float = 0.0
mean_attempted: float = 0.0
mean_solve_rate: float = 0.0
campaign_solve_rates: list[float] = PydanticUndefined
class HarnessTags(ctfy.core.models.CtfyModel):
467class HarnessTags(CtfyModel):
468    """What a registry publishes under one harness image's repository.
469
470    ``image`` is echoed back because the caller asked by harness id and
471    the answer is about the *repository* that id currently points at —
472    without it, a reader cannot tell which reference was looked up.
473    """
474
475    image: str = ""
476    #: Tags, in the order the registry gave them, capped by
477    #: ``ctfy.server.registry_tags.MAX_TAGS``. Empty is a real answer: a
478    #: repository with nothing published.
479    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):
595class HarnessUpdate(CtfyModel):
596    """The fields an owner may change on their own registered agent.
597
598    ⚠️ **A dedicated model, never ``HarnessState``.** The registration
599    route takes the state model and then overwrites five fields it
600    refuses to read from the request — ``owner_user_id`` and
601    ``review_status`` among them, because a field a client can set is a
602    field a client can forge. An update route that took the same model
603    would have to repeat that list correctly forever; here the forbidden
604    fields are simply *unrepresentable*, so the next column added to
605    ``HarnessState`` is not silently editable.
606
607    ⚠️ **Every field optional, and absence means "leave it".** Read with
608    ``model_fields_set``, never ``is not None``: an editor that saves one
609    field at a time and an absent key read as an empty value blanks the
610    rest — the trap the registration-edit and changelog routes both
611    document.
612
613    ``source_url`` keeps its ``WebUrl`` type, so the allow-list that
614    guards every address reaching an ``href`` applies here too. It is
615    rendered on a public leaderboard.
616    """
617
618    display_name: str | None = None
619    #: ⚠️ Changing this resets the evidence — see the route. It is the reason
620    #: the field is editable at all: without it a new version of an
621    #: agent means delete-and-re-register, which throws away the name,
622    #: the notes and the row's history along with the verdict.
623    image: str | None = None
624    version: str | None = None
625    notes: str | None = None
626    source_url: WebUrl | None = None
627    active: bool | None = None
628    default_version_id: str | None = None
629    #: #3473: the wire surface the agent calls; ``""`` clears the declaration.
630    dialect: AgentDialect | 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 0x74f512354e00>)]] = None
active: bool | None = None
default_version_id: str | None = None
dialect: Union[Literal['anthropic_messages', 'openai_chat', 'openai_responses'], Literal[''], NoneType] = None
class HealthResponse(ctfy.core.models.CtfyModel):
23class HealthResponse(CtfyModel):
24    status: str = "ok"
25    hostname: str = ""
26    running_instances: int = 0
27    # Starts the platform-wide cap still admits; ``None`` = no such cap
28    # (``CTFY_MAX_INSTANCES`` unset, #3298), never "full". ⚠️ ``/health``
29    # omits it then rather than sending ``null``: an older CLI declares
30    # ``capacity: int`` and validates this probe on every version check.
31    capacity: int | None = None
32    # Server's installed ``ctfy`` version. Lets the SDK/CLI flag a
33    # client/server skew off the probe they already make — no extra
34    # round trip. Defaults to "" so older servers (and the model's own
35    # default construction) stay valid.
36    version: str = ""
37    # Deployment tier + label, carried on the same probe so the CLI can
38    # warn an operator they're pointed at a non-production deployment (and
39    # add friction to destructive commands on prod) without a second
40    # round trip. ``prod`` is the fail-safe default.
41    environment: Environment = "prod"
42    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 | None = None
version: str = ''
environment: Literal['dev', 'staging', 'prod'] = 'prod'
environment_label: str = ''
class HintAttachmentUpload(ctfy.core.models.CtfyModel):
220class HintAttachmentUpload(CtfyModel):
221    """A file stored for a hint tier, to be put into a draft tier."""
222
223    attachment_key: str
224    attachment_name: str
225    attachment_size: int
226    attachment_type: str

A file stored for a hint tier, to be put into a draft tier.

attachment_key: str = PydanticUndefined
attachment_name: str = PydanticUndefined
attachment_size: int = PydanticUndefined
attachment_type: str = PydanticUndefined
class HintDraftBody(ctfy.core.models.CtfyModel):
214class HintDraftBody(CtfyModel):
215    """Save the hint editor's draft: the whole tier list, in order."""
216
217    tiers: list[HintTierDraft] = Field(default_factory=list, max_length=MAX_HINT_TIERS)

Save the hint editor's draft: the whole tier list, in order.

tiers: list[ctfy.core.state.models.HintTierDraft] = PydanticUndefined
class HintEditorTier(ctfy.core.models.CtfyModel):
162class HintEditorTier(CtfyModel):
163    """One published tier as the organiser sees it."""
164
165    id: str
166    tier: int
167    body: str = ""
168    link_url: str = ""
169    #: The stored file, so an edit of a published tier keeps it.
170    attachment_key: str = ""
171    attachment_name: str = ""
172    attachment_size: int = 0
173    attachment_type: str = ""
174    cost_percent: int = 100
175    cost_points: int = 0
176    rule: ReleaseRule | None = None
177    status: ReleaseStatus = "manual"
178    released_at: datetime | None = None
179    #: How many teams opened it.
180    views: int = 0

One published tier as the organiser sees it.

id: str = PydanticUndefined
tier: int = PydanticUndefined
body: str = ''
attachment_key: str = ''
attachment_name: str = ''
attachment_size: int = 0
attachment_type: str = ''
cost_percent: int = 100
cost_points: int = 0
rule: ctfy.core.release_conditions.ReleaseRule | None = None
status: Literal['released', 'scheduled', 'conditional', 'manual'] = 'manual'
released_at: datetime.datetime | None = None
views: int = 0
class HintEditorView(ctfy.core.models.CtfyModel):
195class HintEditorView(CtfyModel):
196    """Everything the hint editor shows for one challenge."""
197
198    competition_id: str
199    challenge_id: str
200    challenge_name: str = ""
201    challenge_released_at: datetime | None = None
202    wave: str = ""
203    now: datetime
204    tiers: list[HintEditorTier] = Field(default_factory=list)
205    #: The unpublished list, or ``None`` when there is no draft.
206    draft: list[HintTierDraft] | None = None
207    draft_saved_at: datetime | None = None
208    findings: list[ReleaseFindingInfo] = Field(default_factory=list)
209    scenarios: list[HintScenario] = Field(default_factory=list)
210    hint_cost_mode: HintCostMode = "discount"
211    effective_hint_cost_mode: HintCostMode = "discount"

Everything the hint editor shows for one challenge.

competition_id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
challenge_name: str = ''
challenge_released_at: datetime.datetime | None = None
wave: str = ''
now: datetime.datetime = PydanticUndefined
tiers: list[HintEditorTier] = PydanticUndefined
draft: list[ctfy.core.state.models.HintTierDraft] | None = None
draft_saved_at: datetime.datetime | None = None
findings: list[ReleaseFindingInfo] = PydanticUndefined
scenarios: list[HintScenario] = PydanticUndefined
hint_cost_mode: Literal['discount', 'fixed', 'record'] = 'discount'
effective_hint_cost_mode: Literal['discount', 'fixed', 'record'] = 'discount'
class HintScenario(ctfy.core.models.CtfyModel):
183class HintScenario(CtfyModel):
184    """When each tier would be released in one hypothetical ("情形预览"):
185    ``unsolved`` — nobody finishes the challenge; ``solved`` — teams finish
186    it at ``solved_at`` (``teams`` of them)."""
187
188    kind: Literal["unsolved", "solved"]
189    solved_at: datetime | None = None
190    teams: int = 1
191    #: One per tier of the draft (or the published list), in order.
192    tier_release: list[datetime | None] = Field(default_factory=list)

When each tier would be released in one hypothetical ("情形预览"): unsolved — nobody finishes the challenge; solved — teams finish it at solved_at (teams of them).

kind: Literal['unsolved', 'solved'] = PydanticUndefined
solved_at: datetime.datetime | None = None
teams: int = 1
tier_release: list[datetime.datetime | None] = PydanticUndefined
class HintViewResult(ctfy.core.models.CtfyModel):
281class HintViewResult(CtfyModel):
282    """``POST .../hints/{hint_id}/view`` — the tier, now open."""
283
284    tier: PlayerHintTier
285    #: True iff this call recorded the view (a repeat costs nothing).
286    charged: bool = False
287    kept_percent: int = 100
288    penalty: int = 0

POST .../hints/{hint_id}/view — the tier, now open.

tier: PlayerHintTier = PydanticUndefined
charged: bool = False
kept_percent: int = 100
penalty: int = 0
class HumanCheckRequirement(ctfy.core.models.CtfyModel):
12class HumanCheckRequirement(CtfyModel):
13    """What a caller would be asked for one protection point, right now."""
14
15    point: HumanCheckPoint
16    #: Whether a solved challenge must ride the next request.
17    required: bool
18    #: Which verifier to run in the browser.
19    provider: HumanCheckProvider
20    #: The Turnstile site key when ``provider`` is ``turnstile``, else empty.
21    site_key: str = ""

What a caller would be asked for one protection point, right now.

point: ctfy.core.enums.HumanCheckPoint = PydanticUndefined
required: bool = PydanticUndefined
provider: ctfy.core.enums.HumanCheckProvider = PydanticUndefined
site_key: str = ''
class ImageConsistencyRow(ctfy.core.models.CtfyModel):
284class ImageConsistencyRow(CtfyModel):
285    """One ``repository:tag`` the fleet disagrees on — the wire shape of
286    :class:`ctfy.core.image_consistency.ImageDivergence`.
287
288    ``by_digest`` maps each distinct digest to the node ids holding it, so
289    the admin page can render "s2, s3 hold ff2cb7e…; cb holds f5dcfb…"
290    without re-deriving anything. ``digest_count`` / ``node_count`` are
291    ``computed_field``s — this is a wire model reaching a client, the axis
292    :class:`~ctfy.core.state.models.TeamState`'s ``@property`` sits on the
293    other side of.
294    """
295
296    ref: str
297    by_digest: dict[str, list[str]]
298
299    @computed_field
300    @property
301    def digest_count(self) -> int:
302        return len(self.by_digest)
303
304    @computed_field
305    @property
306    def node_count(self) -> int:
307        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
299    @computed_field
300    @property
301    def digest_count(self) -> int:
302        return len(self.by_digest)
node_count: int
304    @computed_field
305    @property
306    def node_count(self) -> int:
307        return sum(len(nodes) for nodes in self.by_digest.values())
class InboxAnnouncement(ctfy.core.models.CtfyModel):
309class InboxAnnouncement(CtfyModel):
310    """Announcement projected onto the Inbox surface.
311
312    Carries the same fields as :class:`AnnouncementInfo` plus a
313    per-user ``is_read`` flag derived from the
314    ``announcement_reads`` table. The frontend renders unread rows
315    prominently and lets the user mark them read via
316    ``POST /me/announcements/{id}/read``.
317    """
318
319    id: str
320    title: str
321    body: str
322    severity: AnnouncementSeverity
323    starts_at: datetime | None = None
324    ends_at: datetime | None = None
325    created_at: datetime | None = None
326    updated_at: datetime | None = None
327    created_by: str
328    created_by_name: str
329    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 InboxApplicationQueue(ctfy.core.models.CtfyModel):
428class InboxApplicationQueue(CtfyModel):
429    """People waiting to be let into a private competition, per competition
430    (#2558).
431
432    Only for who may rule on an application (a platform admin, or a
433    competition admin with the full ``admin`` role -- a roster reviewer
434    cannot invite), only while some wait and the event has not ended.
435    """
436
437    competition_id: str
438    competition_title: str
439    #: The competition's title per locale (#2230); a browser renders this,
440    #: ``competition_title`` is its base-locale reading.
441    competition_title_i18n: LocalizedText = Field(default_factory=dict)
442    pending: int = 0
443    starts_at: datetime | None = None

People waiting to be let into a private competition, per competition (#2558).

Only for who may rule on an application (a platform admin, or a competition admin with the full admin role -- a roster reviewer cannot invite), only while some wait and the event has not ended.

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_title_i18n: dict[str, str] = PydanticUndefined
pending: int = 0
starts_at: datetime.datetime | None = None
class InboxAuthoringQueue(ctfy.core.models.CtfyModel):
480class InboxAuthoringQueue(CtfyModel):
481    """Player-authored uploads waiting for a first review (#2561).
482
483    ``competition_id`` is empty on a platform admin's one summary card, which
484    counts every live competition (``competitions`` of them); otherwise it is
485    the one competition the caller organises or reviews authored uploads for.
486    """
487
488    competition_id: str = ""
489    competition_title: str = ""
490    competition_title_i18n: LocalizedText = Field(default_factory=dict)
491    pending: int = 0
492    #: On the summary card: how many competitions hold the ``pending``.
493    competitions: int = 0
494    oldest_submitted_at: datetime | None = None
495    starts_at: datetime | None = None

Player-authored uploads waiting for a first review (#2561).

competition_id is empty on a platform admin's one summary card, which counts every live competition (competitions of them); otherwise it is the one competition the caller organises or reviews authored uploads for.

competition_id: str = ''
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
pending: int = 0
competitions: int = 0
oldest_submitted_at: datetime.datetime | None = None
starts_at: datetime.datetime | None = None
class InboxCaptainRequest(ctfy.core.models.CtfyModel):
256class InboxCaptainRequest(CtfyModel):
257    """Pending join request against any team the calling user
258    captains. Frontend renders an Approve / Reject pair pointing at
259    ``/competitions/{competition_id}/invites/{invite_id}/approve|reject``.
260    """
261
262    id: str
263    competition_id: str
264    competition_title: str
265    #: The competition's title per locale (#2230); a browser renders this,
266    #: ``competition_title`` is its base-locale reading.
267    competition_title_i18n: LocalizedText = Field(default_factory=dict)
268    team_id: str
269    team_name: str
270    requester_user_id: str
271    requester_display_name: str
272    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
competition_title_i18n: dict[str, 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):
275class InboxCompetitionInvite(CtfyModel):
276    """A standing invitation to participate in a PRIVATE competition the
277    calling user has been granted but not yet joined.
278
279    Unlike the team invites above this isn't accept/decline — the
280    allowlist row is a standing permission, so the frontend renders a
281    "Register" CTA linking to ``/competitions/{competition_id}/team``.
282    Drops off the inbox once the user registers (or the comp ends / is
283    flipped back to public).
284    """
285
286    competition_id: str
287    competition_title: str
288    #: The competition's title per locale (#2230); a browser renders this,
289    #: ``competition_title`` is its base-locale reading.
290    competition_title_i18n: LocalizedText = Field(default_factory=dict)
291    competition_phase: Literal["upcoming", "running", "past"]
292    invited_by_user_id: str = ""
293    invited_by_display_name: str = ""
294    invited_at: datetime | None = None
295    #: Why the "Register" CTA above cannot be offered yet, or ``""``.
296    #:
297    #: ⚠️ **The row stays on the inbox when this is set.** Filtering the
298    #: card out is the shorter fix and it tells the invitee nothing:
299    #: "invited, waiting on the organiser" and "never invited" would
300    #: render identically, and the first is the one somebody is about to
301    #: ask about. The card explains instead, and the button is what goes.
302    #:
303    #: ⚠️ **``""`` is the open answer**, so a server too old to send the
304    #: field leaves the button exactly where it was — every gate here
305    #: fails open, and a wrong "blocked" takes a live registration away.
306    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_title_i18n: dict[str, 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 InboxFacet(ctfy.core.models.CtfyModel):
408class InboxFacet(CtfyModel):
409    """One entry of the event filter: an event the caller has messages in."""
410
411    #: ``""`` is the platform and the caller's own account.
412    competition_id: str = ""
413    competition_title: str = ""
414    competition_title_i18n: LocalizedText = Field(default_factory=dict)
415    #: ``running`` / ``upcoming`` / ``past``; ``""`` for the platform.
416    phase: CompetitionPhase | Literal[""] = ""
417    total: int = 0
418    unread: int = 0
419    archived: bool = False

One entry of the event filter: an event the caller has messages in.

competition_id: str = ''
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
phase: Union[Literal['upcoming', 'running', 'past'], Literal['']] = ''
total: int = 0
unread: int = 0
archived: bool = False
class InboxIncomingInvite(ctfy.core.models.CtfyModel):
217class InboxIncomingInvite(CtfyModel):
218    """Pending direct invite where the calling user is the named target.
219
220    The frontend renders an Accept / Decline pair pointing at
221    ``/competitions/{competition_id}/invites/{invite_id}/accept|decline``.
222    """
223
224    id: str
225    competition_id: str
226    competition_title: str
227    #: The competition's title per locale (#2230); a browser renders this,
228    #: ``competition_title`` is its base-locale reading.
229    competition_title_i18n: LocalizedText = Field(default_factory=dict)
230    team_id: str
231    team_name: str
232    captain_user_id: str
233    captain_display_name: str
234    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
competition_title_i18n: dict[str, 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 InboxItem(ctfy.core.models.CtfyModel):
366class InboxItem(CtfyModel):
367    """One entry of the inbox stream. Exactly the field ``kind`` names is set."""
368
369    #: Stable across reads: ``<kind>:<id>``.
370    key: str
371    kind: InboxItemKind
372    type: InboxItemType
373    competition_id: str = ""
374    competition_title: str = ""
375    competition_title_i18n: LocalizedText = Field(default_factory=dict)
376    at: datetime | None = None
377    unread: bool = False
378    #: Waiting on the caller: an invitation, a request, a thread whose turn is
379    #: theirs. Pinned above the stream until answered.
380    pending_units: int = Field(default=0, ge=0)
381
382    @computed_field
383    @property
384    def pending(self) -> bool:
385        """Compatibility projection: actionable units are the source of truth."""
386        return self.pending_units > 0
387
388    thread: MessageThreadRow | None = None
389    announcement: InboxAnnouncement | None = None
390    incoming_invite: InboxIncomingInvite | None = None
391    captain_request: InboxCaptainRequest | None = None
392    outgoing_request: InboxOutgoingRequest | None = None
393    competition_invite: InboxCompetitionInvite | None = None
394    machine_notice: InboxMachineNotice | None = None
395    release_notice: InboxReleaseNotice | None = None
396    review_verdict: InboxReviewVerdict | None = None
397    risk_notice: InboxRiskNotice | None = None
398    review_queue: InboxReviewQueue | None = None
399    application_queue: InboxApplicationQueue | None = None
400    risk_queue: InboxRiskQueue | None = None
401    shared_env_reports: InboxSharedEnvReports | None = None
402    authoring_queue: InboxAuthoringQueue | None = None
403    registration_todo: InboxRegistrationTodo | None = None
404    record: InboxRecord | None = None
405    notification: InboxNotification | None = None

One entry of the inbox stream. Exactly the field kind names is set.

key: str = PydanticUndefined
kind: Literal['thread', 'announcement', 'incoming_invite', 'captain_request', 'outgoing_request', 'competition_invite', 'machine_notice', 'release_notice', 'review_verdict', 'risk_notice', 'review_queue', 'application_queue', 'risk_queue', 'shared_env_reports', 'authoring_queue', 'registration_todo', 'record', 'notification'] = PydanticUndefined
type: Literal['conversation', 'announcement', 'team', 'notice'] = PydanticUndefined
competition_id: str = ''
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
at: datetime.datetime | None = None
unread: bool = False
pending_units: int = 0
pending: bool
382    @computed_field
383    @property
384    def pending(self) -> bool:
385        """Compatibility projection: actionable units are the source of truth."""
386        return self.pending_units > 0

Compatibility projection: actionable units are the source of truth.

thread: MessageThreadRow | None = None
announcement: InboxAnnouncement | None = None
incoming_invite: InboxIncomingInvite | None = None
captain_request: InboxCaptainRequest | None = None
outgoing_request: InboxOutgoingRequest | None = None
competition_invite: InboxCompetitionInvite | None = None
machine_notice: InboxMachineNotice | None = None
release_notice: InboxReleaseNotice | None = None
review_verdict: InboxReviewVerdict | None = None
risk_notice: InboxRiskNotice | None = None
review_queue: InboxReviewQueue | None = None
application_queue: InboxApplicationQueue | None = None
risk_queue: InboxRiskQueue | None = None
shared_env_reports: InboxSharedEnvReports | None = None
authoring_queue: InboxAuthoringQueue | None = None
registration_todo: InboxRegistrationTodo | None = None
record: InboxRecord | None = None
notification: ctfy.server.models.messages.InboxNotification | None = None
class InboxMachineNotice(ctfy.core.models.CtfyModel):
168class InboxMachineNotice(CtfyModel):
169    """A captain's inbox card: your team's machine is ready (#2132).
170
171    Derived from the assignment (live, assigned, announced), so a revoked
172    machine's card is gone by construction. Carries no host and no login.
173    """
174
175    competition_id: str
176    competition_title: str = ""
177    #: The competition's title per locale (#2230); a browser renders this,
178    #: ``competition_title`` is its base-locale reading.
179    competition_title_i18n: LocalizedText = Field(default_factory=dict)
180    team_id: str
181    team_name: str = ""
182    notified_at: datetime | None = None

A captain's inbox card: your team's machine is ready (#2132).

Derived from the assignment (live, assigned, announced), so a revoked machine's card is gone by construction. Carries no host and no login.

competition_id: str = PydanticUndefined
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = ''
notified_at: datetime.datetime | None = None
class InboxOutgoingRequest(ctfy.core.models.CtfyModel):
237class InboxOutgoingRequest(CtfyModel):
238    """Pending join request the calling user opened — surfaces so the
239    requester can see "did the captain see this yet?" without
240    refreshing the team page.
241    """
242
243    id: str
244    competition_id: str
245    competition_title: str
246    #: The competition's title per locale (#2230); a browser renders this,
247    #: ``competition_title`` is its base-locale reading.
248    competition_title_i18n: LocalizedText = Field(default_factory=dict)
249    team_id: str
250    team_name: str
251    captain_user_id: str
252    captain_display_name: str
253    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
competition_title_i18n: dict[str, 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 InboxOverview(ctfy.core.models.CtfyModel):
422class InboxOverview(CtfyModel):
423    """What sits above the stream: the pinned items, the event filter's
424    entries, and the unread counts."""
425
426    pinned: list[InboxItem] = Field(default_factory=list)
427    #: All pinned items the scope holds; ``pinned`` stops at 50 threads.
428    pinned_total: int = 0
429    facets: list[InboxFacet] = Field(default_factory=list)
430    unread: int = 0
431    #: Work units, distinct from pinned cards and unread messages.
432    pending: int = 0
433    #: Union of unread and pending work; a pending unread thread counts once.
434    attention: int = 0
435    unread_announcements: int = 0
436    unread_messages_by_competition: dict[str, int] = Field(default_factory=dict)

What sits above the stream: the pinned items, the event filter's entries, and the unread counts.

pinned: list[InboxItem] = PydanticUndefined
pinned_total: int = 0
facets: list[InboxFacet] = PydanticUndefined
unread: int = 0
pending: int = 0
attention: int = 0
unread_announcements: int = 0
unread_messages_by_competition: dict[str, int] = PydanticUndefined
class InboxRecord(ctfy.core.models.CtfyModel):
310class InboxRecord(CtfyModel):
311    """A kept record (#2284): something the account did or was told, which
312    the live items stop stating once it is answered. The names are resolved
313    when read -- a renamed team reads by its current name, an erased person
314    as an empty label."""
315
316    id: str
317    kind: RecordKind
318    competition_id: str = ""
319    team_id: str = ""
320    team_name: str = ""
321    #: Whose request was handled, as the account may name them.
322    subject_label: str = ""
323    #: The account's own token name.
324    ref: str = ""
325    created_at: datetime | None = None

A kept record (#2284): something the account did or was told, which the live items stop stating once it is answered. The names are resolved when read -- a renamed team reads by its current name, an erased person as an empty label.

id: str = PydanticUndefined
kind: Literal['join_request_approved', 'join_request_rejected', 'invite_accepted', 'invite_declined', 'invite_accepted_by', 'invite_declined_by', 'token_created', 'token_rotated'] = PydanticUndefined
competition_id: str = ''
team_id: str = ''
team_name: str = ''
subject_label: str = ''
ref: str = ''
created_at: datetime.datetime | None = None
class InboxRegistrationTodo(ctfy.core.models.CtfyModel):
369class InboxRegistrationTodo(CtfyModel):
370    """「需要你处理」 (#2527): what a rejection asked the caller to fix, per team.
371
372    Derived from the to-do rows, never stored as a message: once the caller's
373    last to-do is done the card is gone. Only the caller's own items (a
374    squad-level one for the captain); a teammate's are not here.
375    """
376
377    competition_id: str
378    competition_title: str
379    #: The competition's title per locale (#2230); a browser renders this,
380    #: ``competition_title`` is its base-locale reading.
381    competition_title_i18n: LocalizedText = Field(default_factory=dict)
382    team_id: str
383    team_name: str
384    open: int = 0
385    items: list[InboxTodoEntry] = Field(default_factory=list)
386    reviewed_at: datetime | None = None
387    registration_ends_at: datetime | None = None

「需要你处理」 (#2527): what a rejection asked the caller to fix, per team.

Derived from the to-do rows, never stored as a message: once the caller's last to-do is done the card is gone. Only the caller's own items (a squad-level one for the captain); a teammate's are not here.

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_title_i18n: dict[str, str] = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
open: int = 0
items: list[InboxTodoEntry] = PydanticUndefined
reviewed_at: datetime.datetime | None = None
registration_ends_at: datetime.datetime | None = None
class InboxReleaseNotice(ctfy.core.models.CtfyModel):
291class InboxReleaseNotice(CtfyModel):
292    """A release the reader's team should hear about (#2093, #2101): new
293    challenges of a competition, or a new hint on a challenge the team
294    opened and has not finished. Derived from ``release_records``."""
295
296    kind: Literal["challenges", "hint"]
297    competition_id: str
298    competition_title: str = ""
299    #: The competition's title per locale (#2230); a browser renders this,
300    #: ``competition_title`` is its base-locale reading.
301    competition_title_i18n: LocalizedText = Field(default_factory=dict)
302    challenge_ids: list[str] = Field(default_factory=list)
303    challenge_names: list[str] = Field(default_factory=list)
304    tier: int = 0
305    released_at: datetime

A release the reader's team should hear about (#2093, #2101): new challenges of a competition, or a new hint on a challenge the team opened and has not finished. Derived from release_records.

kind: Literal['challenges', 'hint'] = PydanticUndefined
competition_id: str = PydanticUndefined
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
challenge_ids: list[str] = PydanticUndefined
challenge_names: list[str] = PydanticUndefined
tier: int = 0
released_at: datetime.datetime = PydanticUndefined
class InboxResponse(ctfy.core.models.CtfyModel):
498class InboxResponse(CtfyModel):
499    """``GET /me/inbox`` payload — pending invites/requests +
500    actionable announcements grouped by the action the calling
501    user can take.
502    """
503
504    incoming_invites: list[InboxIncomingInvite] = Field(default_factory=list)
505    outgoing_requests: list[InboxOutgoingRequest] = Field(default_factory=list)
506    captain_requests: list[InboxCaptainRequest] = Field(default_factory=list)
507    # Standing participation invites to private competitions the user
508    # hasn't joined yet (rendered with a "Register" CTA, not accept/decline).
509    competition_invites: list[InboxCompetitionInvite] = Field(default_factory=list)
510    # Site-wide announcements relevant right now (currently live + a
511    # short tail of recently-expired) with the user's read state.
512    announcements: list[InboxAnnouncement] = Field(default_factory=list)
513    # Convenience count so the sidebar can render an unread badge
514    # without re-iterating ``announcements``.
515    unread_announcement_count: int = 0
516    # "Your team's machine is ready" cards (#2132): one per captain team whose
517    # live machine the organisers have announced. Derived from the assignment,
518    # so a revoked machine's card is gone; carries no host and no login.
519    machine_notices: list[InboxMachineNotice] = Field(default_factory=list)
520    # #2073: site messages with something the caller has not read, outside
521    # the events they archived -- in all, and per event (``""`` is the
522    # platform's own threads). The messages themselves are read through
523    # ``GET /me/inbox/items``.
524    unread_message_count: int = 0
525    unread_messages_by_competition: dict[str, int] = Field(default_factory=dict)
526    # New challenges and new hints (#2093, #2101): one card per release the
527    # reader's team should hear about, for a day. Derived from the release
528    # records, so nothing is stored per reader.
529    release_notices: list[InboxReleaseNotice] = Field(default_factory=list)
530    # #2223: the organisers' verdict on each of the caller's registrations,
531    # what they did about the caller's teams (with appeal state and replies),
532    # and -- for organisers -- the review queues waiting on them. All derived
533    # from existing rows, so none carries a read state of its own.
534    review_verdicts: list[InboxReviewVerdict] = Field(default_factory=list)
535    risk_notices: list[InboxRiskNotice] = Field(default_factory=list)
536    review_queues: list[InboxReviewQueue] = Field(default_factory=list)
537    # #2558: private-competition applications waiting on the caller, one card
538    # per competition, counted at read time.
539    application_queues: list[InboxApplicationQueue] = Field(default_factory=list)
540    # #2560: risk cases and appeals waiting on the caller, one card per
541    # competition, counted at read time.
542    risk_queues: list[InboxRiskQueue] = Field(default_factory=list)
543    # #2562: shared environments players reported broken, same rule.
544    shared_env_reports: list[InboxSharedEnvReports] = Field(default_factory=list)
545    # #2561: first uploads waiting for review (a platform admin gets one summary).
546    authoring_queues: list[InboxAuthoringQueue] = Field(default_factory=list)
547    # #2527: what a rejection asked the caller to fix, one card per team.
548    registration_todos: list[InboxRegistrationTodo] = Field(default_factory=list)

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
machine_notices: list[InboxMachineNotice] = PydanticUndefined
unread_message_count: int = 0
unread_messages_by_competition: dict[str, int] = PydanticUndefined
release_notices: list[InboxReleaseNotice] = PydanticUndefined
review_verdicts: list[InboxReviewVerdict] = PydanticUndefined
risk_notices: list[InboxRiskNotice] = PydanticUndefined
review_queues: list[InboxReviewQueue] = PydanticUndefined
application_queues: list[InboxApplicationQueue] = PydanticUndefined
risk_queues: list[InboxRiskQueue] = PydanticUndefined
shared_env_reports: list[InboxSharedEnvReports] = PydanticUndefined
authoring_queues: list[InboxAuthoringQueue] = PydanticUndefined
registration_todos: list[InboxRegistrationTodo] = PydanticUndefined
class InboxReviewQueue(ctfy.core.models.CtfyModel):
406class InboxReviewQueue(CtfyModel):
407    """Teams waiting on the caller's review, per competition (#2223).
408
409    Only for someone who may rule on them (a platform admin, or a
410    competition admin / reviewer of this event), only while some wait.
411    """
412
413    competition_id: str
414    competition_title: str
415    #: The competition's title per locale (#2230); a browser renders this,
416    #: ``competition_title`` is its base-locale reading.
417    competition_title_i18n: LocalizedText = Field(default_factory=dict)
418    pending: int = 0
419    #: Of ``pending``, how many were rejected before and came back after an
420    #: edit -- the ones to look at again.
421    resubmitted: int = 0
422    #: Whether the competition keeps unapproved teams out of play: the queue
423    #: must then be cleared before the start.
424    gates_play: bool = False
425    starts_at: datetime | None = None

Teams waiting on the caller's review, per competition (#2223).

Only for someone who may rule on them (a platform admin, or a competition admin / reviewer of this event), only while some wait.

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_title_i18n: dict[str, str] = PydanticUndefined
pending: int = 0
resubmitted: int = 0
gates_play: bool = False
starts_at: datetime.datetime | None = None
class InboxReviewVerdict(ctfy.core.models.CtfyModel):
332class InboxReviewVerdict(CtfyModel):
333    """The organisers' verdict on the caller's team registration (#2223).
334
335    Derived from the team row, never stored as a message: the card says what
336    *is*, so a verdict an organiser revises is never shown in its old form.
337    Shown to every member of the team, in a competition that runs review and
338    has not ended, once a verdict exists -- or, where the gate is on, while
339    the team waits for its first one (it cannot play until then).
340    """
341
342    competition_id: str
343    competition_title: str
344    #: The competition's title per locale (#2230); a browser renders this,
345    #: ``competition_title`` is its base-locale reading.
346    competition_title_i18n: LocalizedText = Field(default_factory=dict)
347    team_id: str
348    team_name: str
349    review_status: RegistrationReviewStatus
350    #: The organiser's reason. Plain text, shown as written.
351    review_note: str = ""
352    reviewed_at: datetime | None = None
353    #: ``pending`` again after a verdict: a member edited the registration
354    #: (after a rejection, or a key answer after an approval) and it is back
355    #: in the organisers' queue.
356    resubmitted: bool = False
357    #: Whether this verdict keeps the team out of play (the competition's
358    #: ``play_requires_approval``). Composed server-side.
359    blocks_play: bool = False

The organisers' verdict on the caller's team registration (#2223).

Derived from the team row, never stored as a message: the card says what is, so a verdict an organiser revises is never shown in its old form. Shown to every member of the team, in a competition that runs review and has not ended, once a verdict exists -- or, where the gate is on, while the team waits for its first one (it cannot play until then).

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_title_i18n: dict[str, str] = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
review_status: Literal['pending', 'approved', 'rejected'] = PydanticUndefined
review_note: str = ''
reviewed_at: datetime.datetime | None = None
resubmitted: bool = False
blocks_play: bool = False
class InboxRiskNotice(ctfy.core.models.CtfyModel):
390class InboxRiskNotice(CtfyModel):
391    """Something the organisers did about the caller's team (#2068), with its
392    appeal state and deadline -- the same :class:`RiskNotice` the competition
393    page's banner draws, so the two cannot disagree. A silent action (not
394    notified to the team) is never here, because ``notices_for`` drops it."""
395
396    competition_id: str
397    competition_title: str
398    #: The competition's title per locale (#2230); a browser renders this,
399    #: ``competition_title`` is its base-locale reading.
400    competition_title_i18n: LocalizedText = Field(default_factory=dict)
401    team_id: str
402    team_name: str
403    notice: RiskNotice

Something the organisers did about the caller's team (#2068), with its appeal state and deadline -- the same RiskNotice the competition page's banner draws, so the two cannot disagree. A silent action (not notified to the team) is never here, because notices_for drops it.

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_title_i18n: dict[str, str] = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
notice: RiskNotice = PydanticUndefined
class InboxRiskQueue(ctfy.core.models.CtfyModel):
446class InboxRiskQueue(CtfyModel):
447    """Risk-control work waiting on the organisers, per competition (#2560).
448
449    Open cases and appeals owed an answer, both counted when read. Only for
450    who may rule on them (a platform admin, or a competition admin with the
451    full ``admin`` role -- a roster reviewer cannot warn, void or disqualify).
452    """
453
454    competition_id: str
455    competition_title: str
456    #: The competition's title per locale (#2230); a browser renders this,
457    #: ``competition_title`` is its base-locale reading.
458    competition_title_i18n: LocalizedText = Field(default_factory=dict)
459    open_cases: int = 0
460    pending_appeals: int = 0
461    starts_at: datetime | None = None

Risk-control work waiting on the organisers, per competition (#2560).

Open cases and appeals owed an answer, both counted when read. Only for who may rule on them (a platform admin, or a competition admin with the full admin role -- a roster reviewer cannot warn, void or disqualify).

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_title_i18n: dict[str, str] = PydanticUndefined
open_cases: int = 0
pending_appeals: int = 0
starts_at: datetime.datetime | None = None
class InboxSharedEnvReports(ctfy.core.models.CtfyModel):
464class InboxSharedEnvReports(CtfyModel):
465    """Shared environments players have reported broken, per competition
466    (#2562): the open reports, counted when read, so a reset or a dismissal
467    takes the card away. For a platform admin and the event's full
468    organisers -- the people who may reset an environment."""
469
470    competition_id: str
471    competition_title: str
472    competition_title_i18n: LocalizedText = Field(default_factory=dict)
473    #: Distinct environments with at least one open report.
474    environments: int = 0
475    #: Open reports across them.
476    reports: int = 0
477    starts_at: datetime | None = None

Shared environments players have reported broken, per competition (#2562): the open reports, counted when read, so a reset or a dismissal takes the card away. For a platform admin and the event's full organisers -- the people who may reset an environment.

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_title_i18n: dict[str, str] = PydanticUndefined
environments: int = 0
reports: int = 0
starts_at: datetime.datetime | None = None
class InboxTodoEntry(ctfy.core.models.CtfyModel):
362class InboxTodoEntry(CtfyModel):
363    """One open to-do on an inbox card: the item and, for a field, its key."""
364
365    item: RegistrationTodoItem
366    field: str = ""

One open to-do on an inbox card: the item and, for a field, its key.

item: Literal['field', 'verify_school_email', 'add_member'] = PydanticUndefined
field: str = ''
class InstanceAnswersResponse(ctfy.core.models.CtfyModel):
460class InstanceAnswersResponse(CtfyModel):
461    """Every answer minted for one live instance.
462
463    ⚠️ **The one place the platform hands out groundtruth.** Everything
464    else is deliberately the other way round: the catalog redacts gated
465    prompts, ``AdminChallengeQuestions`` widens *who may read the
466    question* and says in its own docstring that it does not widen
467    *what is said*, and ``POST /admin/answer-trace`` attributes a value
468    the caller already holds without ever emitting one. This route
469    exists because an organiser debugging "the team swears they
470    submitted this" has no other way to compare, and it is scoped so
471    the cost of that is bounded and visible:
472
473    * **live instances only** — an archived record carries the digest,
474      not the plaintext, so there is nothing here to give out and the
475      route says so rather than returning an empty list that reads as
476      "no answers were minted";
477    * **one instance per request**, never a listing, so reading the
478      whole event is N deliberate acts rather than one page;
479    * **audited** — every read emits ``instance_answers_revealed``
480      naming the admin, and the row carries the count and no value.
481
482    A static ``answer:`` is not here either: it lives in
483    ``metadata.yaml`` and is the same for every team, so it is not a
484    per-instance fact and this is not the route that would answer it.
485    """
486
487    instance_id: str
488    team_id: str = ""
489    challenge_id: str = ""
490    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):
112class InstanceInfo(CtfyModel):
113    id: str
114    challenge_id: str = ""
115    #: Whether this running box can reach the public internet (#1969) —
116    #: the verdict it was started with, held by the worker's host firewall.
117    #: ``deny``: downloads, public APIs and public DNS fail from inside;
118    #: the challenge's own services, the LLM gateway and your way in
119    #: (the instance URLs, the VPN) work as usual. ``allow`` for every box
120    #: an older server started.
121    internet: InternetAccess = InternetAccess.ALLOW
122    team_id: str = ""
123    # Competition the instance is scoped to. Stamped from the resolved
124    # per-comp team at start time; empty for legacy rows or unscoped
125    # admin instances. Frontend keys per-comp instance pages off this
126    # field so a user registered for two comps doesn't see comp A's
127    # instances in comp B's tab.
128    competition_id: str = ""
129    # Worker node currently hosting this instance. Empty only for
130    # records created before nodes were tracked (legacy).
131    node_id: str = ""
132    # Display names for the two ids above, so an admin table can render
133    # what it links to rather than a truncated uuid. Resolved in bulk at
134    # the route from a pre-loaded map; ``""`` means "not resolved", which
135    # is what a caller that passes no map gets and what the client
136    # already renders today (the id, truncated).
137    #
138    # ⚠️ Empty is never the id. `EntityCell` branches on name-vs-id to
139    # decide whether it has something human to show, so echoing the id
140    # here would make "we know the name" indistinguishable from "we
141    # don't" — and every row would render as though it had been resolved.
142    team_name: str = ""
143    node_name: str = ""
144    name: str = ""
145    category: ChallengeCategory | None = None
146    difficulty: str = ""
147    status: InstanceStatus = InstanceStatus.STARTING
148    started_at: float = 0.0
149    ttl: int = 0
150    expires_at: float = 0.0
151    #: ``""`` = running; ``paused`` / ``stopped`` = held between sessions and
152    #: resumed by itself when play opens (#2128). A held box has no
153    #: ``expires_at`` (0): it cannot run out overnight. ``""`` from an older
154    #: server, which never held one.
155    hold: InstanceHold = ""
156    #: A countdown on this box and why: ``session`` (the session ended and
157    #: the event keeps boxes for a grace period) or ``solved`` (a full solve,
158    #: #2105). ``grace_ends_at`` is when it is removed (epoch seconds, 0 =
159    #: none). The player can still stop it earlier.
160    grace_kind: GraceKind = ""
161    grace_ends_at: float = 0.0
162    services: list[ServiceEndpoint] = Field(default_factory=list)
163    description: str = ""
164    # Per-instance question view: same shape as ``ChallengeInfo.questions``
165    # but additionally carries the agent-visible state. ``unlocked`` is
166    # ``False`` when the question's ``requires:`` predecessors haven't
167    # all been answered correctly yet; the UI hides the prompt for locked
168    # questions to avoid leaking route hints. ``answered_correctly`` is
169    # ``True`` once this team has captured this question on this instance.
170    questions: list[InstanceQuestionInfo] = Field(default_factory=list)
171    # The files *this* instance offers its team: the node's scan of the
172    # instance's own ``attachments/`` after templates rendered and the
173    # ``attachment-builder`` ran (``surface.provided_files``). Per-team
174    # artifacts appear here, a placeholder the builder deleted does not,
175    # and each downloads from ``/instances/{id}/attachments/{name}``.
176    # Empty until the instance is ready (#1933). Pre-launch, the
177    # challenge's ``ChallengeInfo.attachments`` is the catalog's listing.
178    attachments: list[AttachmentInfo] = Field(default_factory=list)
179    # How the platform exposes this instance to the player. Always
180    # surfaced (defaults to ``"simple"``) so the agent can branch on
181    # `if info.network_topology == "engagement": ...` without dealing
182    # with a missing field.
183    network_topology: Literal["simple", "engagement"] = "simple"
184    # Which VPN an engagement instance runs. Meaningless in simple
185    # mode, where it stays at the default. Surfaced so the UI can name
186    # the right client command and an agent can branch without
187    # downloading the config to find out what it is.
188    vpn_backend: VpnBackendName = DEFAULT_VPN_BACKEND_NAME
189    # Set only when ``network_topology == "engagement"``. Carries the
190    # tunnel endpoint host:port + the URL the client downloads the
191    # config body from. ``None`` in simple mode.
192    vpn_endpoint: VpnEndpoint | None = None
193    # Landing-page hints declared in the challenge's ``metadata.yaml``
194    # (``entry_urls:`` field, validated by META013). Surfaced verbatim
195    # to the frontend so the VPN / services panel can render a
196    # "start here" list without the player having to read the
197    # description for the canonical first URL.
198    entry_urls: list[str] = Field(default_factory=list)
199    # Which supply this box came from. ``on_demand`` is a player's own
200    # launch and behaves the way every instance always has; ``match`` is
201    # an arena gamebox an organiser provisioned, and it is a genuinely
202    # different object to its owner — it never expires, stopping it is
203    # not theirs to do, and it is the one the round loop rotates flags
204    # into. The client cannot tell them apart from `challenge_id` alone,
205    # because a player may also launch the gamebox on demand.
206    lease: Literal["on_demand", "match"] = "on_demand"
207    #: How many more environments of this challenge the team may build by
208    #: its own hand (#2007): a reset, or a stop followed by a fresh start,
209    #: spend one each. ``None`` when nothing bounds it — the event sets no
210    #: limit — and on every surface that does not compute it (an admin
211    #: listing). ⚠️ ``0`` is the sentence a player must read before
212    #: pressing Stop: this box is the last one, and a new start after
213    #: stopping it is refused. A client reads ``None`` as *no warning*,
214    #: never as *none left*.
215    rebuilds_left: int | None = None
216    #: Tearing this box down loses findings the report has not yet
217    #: recorded (#2007): its groundtruth was seeded per instance, and only
218    #: a live box can check a report against it. ``True`` only in an
219    #: exercise-scored event, on a box whose challenge seeds answers; the
220    #: platform also reads the saved report against it once on the way
221    #: out, so the advice is *save the report first*.
222    save_report_first: bool = False
223    #: The wait, while ``status`` is ``starting`` (#2129). ``None`` on every
224    #: surface that does not compute it (an admin listing) and on an older
225    #: server, which a client reads as *nothing to add*.
226    start_wait: StartWait | None = None
227    #: The start's steps, while it is ``starting`` or after it failed before
228    #: ever being ready (#2719): queued, placed, pull, build, start, health,
229    #: each with its state and times (:mod:`ctfy.core.start_progress`).
230    #: ``None`` = no start to show (a running box, an older server). Phase
231    #: names and times only -- never a log line or a compose message.
232    start_timeline: list[StartStepView] | None = None
233    #: Several teams stand on this one environment (#2129 B). The page shows a
234    #: "shared" chip and replaces the timer and the renew / reset / stop
235    #: buttons with "report a problem". ⚠️ **Deliberately only a flag**: how
236    #: many teams, which group and who are not on any player-visible model
237    #: (``test_group_membership_is_secret``). Such a box also reports
238    #: ``team_id=""``, ``ttl=0`` and ``expires_at=0``.
239    shared: bool = False
240    #: A practice environment (#2183): started after its challenge closed,
241    #: in a competition that lets teams practise. Nothing submitted from it
242    #: scores, it lives the competition's practice TTL and counts against
243    #: its practice cap. An environment started while the challenge scored
244    #: says ``False`` here even after the close; ``ChallengeInfo.scoring``
245    #: is what says whether a submission counts *now*.
246    practice: 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.

id: str = PydanticUndefined
challenge_id: str = ''
internet: ctfy.core.enums.InternetAccess = <InternetAccess.ALLOW: 'allow'>
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
hold: Literal['', 'paused', 'stopped'] = ''
grace_kind: Literal['', 'session', 'solved'] = ''
grace_ends_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'] = 'wireguard'
vpn_endpoint: ctfy.core.target.VpnEndpoint | None = None
entry_urls: list[str] = PydanticUndefined
lease: Literal['on_demand', 'match'] = 'on_demand'
rebuilds_left: int | None = None
save_report_first: bool = False
start_wait: StartWait | None = None
start_timeline: list[ctfy.core.start_progress.StartStepView] | None = None
shared: bool = False
practice: bool = False
class InstanceInternetRequest(ctfy.core.models.CtfyModel):
879class InstanceInternetRequest(CtfyModel):
880    """Body of ``POST /admin/instances/{id}/internet`` (#2067)."""
881
882    internet: InternetAccess
883    reason: InternetChangeReason

Body of POST /admin/instances/{id}/internet (#2067).

internet: ctfy.core.enums.InternetAccess = PydanticUndefined
reason: Annotated[str, StringConstraints(strip_whitespace=True, to_upper=None, to_lower=None, strict=None, min_length=1, max_length=500, pattern=None, ascii_only=None)] = PydanticUndefined
class InstanceInternetResult(ctfy.core.models.CtfyModel):
886class InstanceInternetResult(CtfyModel):
887    """What one operator change of a running box's internet access did (#2067).
888
889    ⚠️ **``internet`` is what the box has *now*, whatever the outcome** —
890    the asked verdict on ``changed`` / ``unchanged``, the old one on
891    ``failed``, because a failed change leaves the box as it was on both
892    sides (the node puts its record back). ``error`` is the
893    ``common:error.<code>`` code naming why, and ``message`` the node's or
894    platform's own sentence; both ``""`` unless ``failed``.
895    """
896
897    instance_id: str
898    team_id: str = ""
899    challenge_id: str = ""
900    node_id: str = ""
901    outcome: InternetChangeOutcome
902    internet: InternetAccess
903    previous_internet: InternetAccess
904    error: str = ""
905    message: str = ""

What one operator change of a running box's internet access did (#2067).

⚠️ internet is what the box has now, whatever the outcome — the asked verdict on changed / unchanged, the old one on failed, because a failed change leaves the box as it was on both sides (the node puts its record back). error is the common:error.<code> code naming why, and message the node's or platform's own sentence; both "" unless failed.

instance_id: str = PydanticUndefined
team_id: str = ''
challenge_id: str = ''
node_id: str = ''
outcome: ctfy.core.enums.InternetChangeOutcome = PydanticUndefined
internet: ctfy.core.enums.InternetAccess = PydanticUndefined
previous_internet: ctfy.core.enums.InternetAccess = PydanticUndefined
error: str = ''
message: str = ''
class InstanceQuestionInfo(ctfy.core.models.CtfyModel):
42class InstanceQuestionInfo(CtfyModel):
43    """One question, projected for a specific running instance.
44
45    Mirrors :class:`QuestionPublicInfo` plus per-instance state.
46
47    ⚠️ **A question the calling team can neither see nor has captured is
48    absent from the list entirely**, rather than present with a blanked
49    ``prompt`` — see ``instances._project_instance_questions`` for why
50    the id, the ``requires`` edges, ``choices`` and the row *count* are
51    each disclosure in their own right. So ``unlocked`` is ``False`` on
52    exactly one shape: a question the team has already captured whose
53    predecessor they never formally answered (a report-credited
54    sentinel), where the prompt stays empty and the row stays because it
55    is a fact about their own work.
56    """
57
58    id: str
59    prompt: str
60    mode: str | None = None
61    choices: list[str] | None = None
62    requires: list[str] = Field(default_factory=list)
63    # ``True`` once every id in ``requires`` has been captured by the
64    # calling team on this instance. ``False`` keeps the prompt empty
65    # to avoid leaking the question text early.
66    unlocked: bool = True
67    # ``True`` once the calling team has submitted a correct answer
68    # for this question against this instance. Drives the UI checkmark.
69    answered_correctly: bool = False
70    # Wrong-attempt budget remaining for the calling team on this
71    # specific question. ``None`` when the question's mode is uncapped
72    # (dynamic free-form, or any mode the operator opted out of via
73    # ``question_attempt_caps``). The UI renders an ``X/N attempts``
74    # badge from this on first page load and decrements it locally on
75    # each wrong submit (the submission response also carries it).
76    attempts_remaining: int | None = None
77    # The cap that ``attempts_remaining`` is being measured against —
78    # echoed so the UI can render ``X/N`` without recomputing N from
79    # the question's mode + current platform settings.
80    attempts_cap: int | None = None
81    # ``True`` for a checker question whose ``check.poll`` is on: the
82    # platform auto-verifies it in the background, so the UI shows an
83    # "auto-verify on" affordance. Never exposes the checker command.
84    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):
317class InstanceRecordArtifacts(CtfyModel):
318    """Per-artifact presence flags + counts for an archived instance.
319
320    Returned alongside the full :class:`InstanceRecord` from
321    ``GET /admin/instance-records/{id}`` so the admin UI can show
322    ``Manifest / Events / Submissions / Container log`` tabs at a glance
323    without a round-trip per tab.
324    """
325
326    has_manifest: bool = False
327    has_container_log: bool = False
328    events_count: int = 0
329    submissions_count: int = 0
330    traffic_count: int = 0
331    # Bytes on disk for the archived ``traffic.pcap``. 0 when no capture
332    # was persisted (sidecar disabled, archive disabled, fetch failed —
333    # or retention reclaimed it, which the two fields below say). The
334    # admin UI uses this both to gate the download button and to render
335    # an "X MB" hint next to it.
336    pcap_bytes: int = 0
337    #: When retention removed this archive's capture files (#1714), or
338    #: ``0`` if it never did. ⚠️ Why the counts above can be zero on an
339    #: instance that was captured: a zero beside a non-zero here is
340    #: *reclaimed*, not *never captured*.
341    capture_reclaimed_at: float = 0.0
342    capture_reclaimed_bytes: int = 0
343    #: When retention removed the rest (manifest, events, submissions,
344    #: container log), or ``0``. The ``InstanceRecord`` row is never
345    #: removed, so the record above stays whole either way.
346    archive_reclaimed_at: float = 0.0
347    archive_reclaimed_bytes: int = 0
348    #: What the node's per-instance capture cap dropped before the
349    #: capture was archived — the oldest segments. ``0`` = nothing
350    #: recorded.
351    pcap_dropped_bytes: int = 0
352    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):
414class InstanceRecordDetail(CtfyModel):
415    record: InstanceRecordInfoDetail
416    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):
260class InstanceRecordInfo(CtfyModel):
261    """Archived-instance summary for the admin history list.
262
263    Mirrors :class:`ctfy.core.state.models.InstanceRecord` minus the heavy
264    fields (``spec``, ``surface``). Used by ``GET /admin/instance-records``.
265    """
266
267    id: str
268    team_id: str = ""
269    challenge_id: str = ""
270    node_id: str = ""
271    # Same contract as ``InstanceInfo.team_name`` / ``node_name`` — see
272    # there. Declared again rather than inherited because this is a
273    # sibling class, not a subclass, so a field added to one is genuinely
274    # absent from the other. The archive needs them at least as much as
275    # the live table: it is read after the event, when the team it names
276    # is likeliest to have been deleted.
277    team_name: str = ""
278    node_name: str = ""
279    name: str = ""
280    category: ChallengeCategory | None = None
281    difficulty: str = ""
282    status: ArchivedInstanceStatus = ""
283    stop_reason: str = ""
284    error: str = ""
285    # Full node diagnostic (e.g. ``docker compose up`` stderr tail).
286    # Admin-only: this model backs ``/admin/instance-records*`` only;
287    # the player-facing ``InstanceInfo`` / ``InstanceStatusResponse``
288    # deliberately omit it.
289    error_detail: str = ""
290    started_at: float = 0.0
291    stopped_at: float = 0.0
292    duration_s: float = 0.0
293    ttl: int = 0
294    solved: bool = False
295    attempts: int = 0
296    # Total HTTP requests captured by the mitmproxy sidecar during the
297    # instance's lifetime. 0 when the archive sink was disabled or no
298    # traffic was captured.
299    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):
302class InstanceRecordInfoDetail(InstanceRecordInfo):
303    """Full record — includes spec + surface, returned by detail endpoint."""
304
305    spec: dict[str, Any] = Field(default_factory=dict)
306    surface: AttackSurface | None = None
307    artifact_dir: str = ""
308    #: Where the start took its built services from (#1852), as facts
309    #: (#2736): the page draws by ``image_source_kind`` and never reads the
310    #: detail for meaning. Read off the live claim or the archive row alike;
311    #: ``kind == ""`` is "not recorded", never "built nothing".
312    image_source_kind: ImageSourceKind = ""
313    image_source_reason: ImageSourceReason = ""
314    image_source_detail: 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_kind: Literal['', 'frozen', 'none', 'built_by_design', 'built_degraded', 'built_fault'] = ''
image_source_reason: Literal['', 'supplied_tree', 'authored_tree', 'no_frozen_pull', 'hash_blind_spot', 'not_frozen', 'pull_timeout', 'lookup_failed'] = ''
image_source_detail: str = ''
class InstanceStatusResponse(ctfy.core.models.CtfyModel):
516class InstanceStatusResponse(CtfyModel):
517    id: str
518    status: InstanceStatus = InstanceStatus.STARTING
519    #: See :class:`StartWait` (#2129). ``None`` once the start is over.
520    start_wait: StartWait | None = None
521    attack_surface: AttackSurface | None = None
522    error: str = ""
523    #: #3065: set when the failure is the platform's and has player copy
524    #: (``common:error.<code>``); the client shows that instead of ``error``.
525    error_code: InstanceErrorCode | Literal[""] = ""
526    # CA volume for the per-instance mitmproxy. Operators trust this CA
527    # if they want to MITM HTTPS; HTTP traffic is captured transparently
528    # without it.
529    cert_volume: str = ""
530    #: Seconds this platform will keep waiting for a start before it
531    #: calls the instance ERROR — so a client waiting for readiness can
532    #: adopt the deadline instead of inventing a shorter one.
533    #: ⚠️ ``0`` means *this server did not say* (it predates the field),
534    #: never *no time at all*: a client reading it as a deadline would
535    #: give up instantly on every older deployment.
536    start_budget_s: int = 0
537    #: Seconds before reading this status again is worth a request: short
538    #: while a start is young, longer as it runs on, longer still once the
539    #: box is ready or gone (``ctfy.core.poll_hint``). Also sent as the
540    #: ``Retry-After`` header. ⚠️ ``0`` means *this server did not say*
541    #: (it predates the field), never *ask again at once* — a client keeps
542    #: its own interval then.
543    poll_after_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'>
start_wait: StartWait | None = None
attack_surface: ctfy.core.target.AttackSurface | None = None
error: str = ''
error_code: Union[ctfy.core.enums.InstanceErrorCode, Literal['']] = ''
cert_volume: str = ''
start_budget_s: int = 0
poll_after_s: int = 0
class InstanceStorageReport(ctfy.core.models.CtfyModel):
397class InstanceStorageReport(CtfyModel):
398    """``GET /admin/instance-storage``: both per-instance stores on disk.
399
400    ⚠️ **Two stores on possibly two hosts, so two sources.** The archive
401    is measured by the platform on its own disk; each node measures its
402    own captures and sends the reading on its heartbeat. A node whose row
403    has no ``report`` has not been measured — a different fact from an
404    empty store, and the page says which.
405    """
406
407    archive: ArchiveStoreReport | None = None
408    captures: list[NodeCaptureStoreRow] = Field(default_factory=list)
409    capture_retention_s: int = 0
410    archive_retention_s: int = 0
411    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):
596class InstanceTrafficPage(CtfyModel):
597    """One poll's worth of captured flows, plus where to resume.
598
599    ``cursor`` is opaque: it is this platform's private encoding of how
600    far each capture file has been read, and nothing outside
601    ``ctfy.server.traffic_tail`` may parse it. A client stores whatever
602    it was handed and sends it back.
603
604    ⚠️ **An empty cursor means "start over", so a client must not invent
605    one.** Sending `""` re-reads the whole capture — which is exactly
606    the cost the tail read exists to remove, arriving through the client
607    rather than through the server.
608    """
609
610    flows: FlowRecords = Field(default_factory=list)
611    cursor: str = ""
612    # A bound was hit and more is already on disk. A client catching up
613    # should poll again immediately rather than waiting out its
614    # interval — the alternative is a defender watching an attack
615    # through a window that fills at one page per interval.
616    truncated: bool = False
617    #: ``""`` / ``"on"`` / ``"off"`` — whether a proxy sidecar exists for
618    #: this instance at all.
619    #:
620    #: ⚠️ **Tri-state, and ``""`` is the default, because the other two
621    #: values are claims.** An empty page means "nothing yet" when a
622    #: sidecar is running and "nothing, ever" when none is, and those
623    #: render as the same empty table — which reads as the feature being
624    #: broken. But a node that never answered has told us neither, and a
625    #: boolean has nowhere to put that. Same rule as
626    #: ``ScoreboardEntry.review_status`` defaulting to ``""`` rather
627    #: than to ``approved``.
628    capture: str = ""
629    #: What redaction did to the flows above, for the surface that
630    #: composed them.
631    #:
632    #: ⚠️ **``""`` is *this surface did not say*, and it is the default
633    #: because the other three are claims.** `raw_traffic_page` is the
634    #: forensic record and redacts nothing on purpose, so
635    #: ``nothing_to_redact`` there would be false in the costly
636    #: direction — it is not that there was nothing to remove, it is
637    #: that this surface does not remove it. The failure paths in
638    #: `instance_traffic._page` never reached a redactor at all and say
639    #: the same. Same reading as ``capture`` above, and
640    #: ``MyEvalRunRow.usage_source``'s recorded correction.
641    #:
642    #: ⚠️ **A client reads ``unscrubbable`` positively** — an older
643    #: server sends no field at all, which must not read as a refusal.
644    redaction: RedactionState = ""
645    #: Which secrets the floor could not replace, **by name**: a
646    #: question id, or a family's name. ⚠️ Never the values — a refusal
647    #: that quotes the secret is the leak it refused to commit.
648    #: Non-empty only beside ``redaction == "unscrubbable"``, and it is
649    #: what makes that refusal actionable rather than a dead end.
650    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):
575class InstanceTrafficWitnesses(CtfyModel):
576    """One poll's worth of witnesses, plus where to resume.
577
578    Same cursor as :class:`InstanceTrafficPage` — a per-file byte
579    offset, opaque, and the two readers advance independently because
580    each holds its own.
581    """
582
583    witnesses: list[TrafficWitness] = Field(default_factory=list)
584    cursor: str = ""
585    #: ⚠️ **A ceiling was hit, so this answer is incomplete rather than
586    #: empty**, and those are opposite advice about a capture. The round
587    #: loop reads it as *unknown*; reading it as "nobody was there"
588    #: would manufacture an accusation out of a budget.
589    truncated: bool = False
590    #: ``""`` / ``"on"`` / ``"off"``, exactly as on the flow page and
591    #: for the same reason: "quiet" and "nothing is watching" render as
592    #: the same empty answer, and only the node can tell them apart.
593    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):
 88class InstitutionCreate(CtfyModel):
 89    """Body for creating or replacing one row.
 90
 91    ⚠️ **``domain`` is the identity, so this is an upsert rather than a
 92    POST-then-PATCH pair.** Renaming one is a delete and a create, which
 93    is what the key being the name means; a PATCH that could move it
 94    would be a second way to reach a decision the delete makes
 95    deliberate.
 96
 97    ⚠️ **``url`` is ``WebUrl``**, the allow-list type, because it reaches
 98    an ``href`` on a page anybody can open.
 99    """
100
101    domain: str = Field(min_length=1, max_length=253)
102    names: dict[str, str] = Field(default_factory=dict)
103    short_name: str = Field(default="", max_length=32)
104    kind: InstitutionKind = "other"
105    eligibility_roles: list[str] = Field(default_factory=list)
106    url: WebUrl | None = None
107    #: Province-level region, typed by the operator (#3023). ``""`` is not
108    #: stated; nothing on the platform infers it.
109    region: InstitutionRegion = ""
110    #: 985 / 211 / 双一流 tags; duplicates collapse. ``[]`` states none.
111    tiers: list[InstitutionTier] = Field(default_factory=list, max_length=8)

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'
eligibility_roles: list[str] = PydanticUndefined
url: Optional[Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x74f512354e00>)]] = None
region: Literal['', 'CN-BJ', 'CN-TJ', 'CN-HE', 'CN-SX', 'CN-NM', 'CN-LN', 'CN-JL', 'CN-HL', 'CN-SH', 'CN-JS', 'CN-ZJ', 'CN-AH', 'CN-FJ', 'CN-JX', 'CN-SD', 'CN-HA', 'CN-HB', 'CN-HN', 'CN-GD', 'CN-GX', 'CN-HI', 'CN-CQ', 'CN-SC', 'CN-GZ', 'CN-YN', 'CN-XZ', 'CN-SN', 'CN-GS', 'CN-QH', 'CN-NX', 'CN-XJ', 'CN-HK', 'CN-MO', 'CN-TW', 'abroad'] = ''
tiers: list[typing.Literal['985', '211', 'double_first_class']] = PydanticUndefined
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    eligibility_roles: list[str] = Field(default_factory=list)
32    url: str = ""
33    created_at: AwareDatetime | None = None
34    updated_at: AwareDatetime | None = None
35    #: Province-level region (#3023); ``""`` is not stated.
36    region: InstitutionRegion = ""
37    #: 985 / 211 / 双一流 tags, in `INSTITUTION_TIERS` order.
38    tiers: list[InstitutionTier] = Field(default_factory=list)
39    #: How many accounts hold a **proved** address here.
40    #:
41    #: ⚠️ **Composed by the route, never stored**: it is a measurement of
42    #: the deployment's own people and would be stale the moment anybody
43    #: verified an address. `InstitutionSuggestion.holders` is the same
44    #: number over the domains nobody has named, which is why they share
45    #: one producer and one query.
46    #:
47    #: ⚠️ **``0`` is a real answer** — "this domain is named and nobody
48    #: here is at it" is exactly what an operator who over-named wants to
49    #: see. The cost of that decision is that a producer which forgets to
50    #: fill it publishes the same ``0``; it is the direction somebody
51    #: reports, on a row whose whole point is a count.
52    holders: int = 0
53
54    @computed_field
55    @property
56    def warning(self) -> str:
57        """Why this stored domain is probably a mistake, or ``""``.
58
59        ⚠️ **Derived rather than stored, and it stays on the row rather
60        than being said once at the write.** `domain_warning` is
61        advisory by construction — a deployment run by one university
62        may mean ``edu.cn`` exactly as typed — so refusing it is wrong
63        and saying it only in the create response means an operator who
64        made the mistake last month never hears about it. A
65        `computed_field` is the wire's half of that decision; this is a
66        wire model, so the state model beside it deliberately has no
67        such column.
68        """
69        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'
eligibility_roles: list[str] = PydanticUndefined
url: str = ''
created_at: pydantic.types.AwareDatetime | None = None
updated_at: pydantic.types.AwareDatetime | None = None
region: Literal['', 'CN-BJ', 'CN-TJ', 'CN-HE', 'CN-SX', 'CN-NM', 'CN-LN', 'CN-JL', 'CN-HL', 'CN-SH', 'CN-JS', 'CN-ZJ', 'CN-AH', 'CN-FJ', 'CN-JX', 'CN-SD', 'CN-HA', 'CN-HB', 'CN-HN', 'CN-GD', 'CN-GX', 'CN-HI', 'CN-CQ', 'CN-SC', 'CN-GZ', 'CN-YN', 'CN-XZ', 'CN-SN', 'CN-GS', 'CN-QH', 'CN-NX', 'CN-XJ', 'CN-HK', 'CN-MO', 'CN-TW', 'abroad'] = ''
tiers: list[typing.Literal['985', '211', 'double_first_class']] = PydanticUndefined
holders: int = 0
warning: str
54    @computed_field
55    @property
56    def warning(self) -> str:
57        """Why this stored domain is probably a mistake, or ``""``.
58
59        ⚠️ **Derived rather than stored, and it stays on the row rather
60        than being said once at the write.** `domain_warning` is
61        advisory by construction — a deployment run by one university
62        may mean ``edu.cn`` exactly as typed — so refusing it is wrong
63        and saying it only in the create response means an operator who
64        made the mistake last month never hears about it. A
65        `computed_field` is the wire's half of that decision; this is a
66        wire model, so the state model beside it deliberately has no
67        such column.
68        """
69        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):
72class InstitutionPage(CtfyModel):
73    """Every named domain.
74
75    ⚠️ **Unpaged, and that is why it carries no ``limit``.** The
76    population is what one operator typed for one deployment and the
77    page is a table they curate, not a feed — `/admin/changelog`'s
78    reasoning. A bare list route that *did* take a window would owe a
79    total, which is what `test_a_list_route_says_how_many` is for; this
80    one takes none, so the count is here for the heading rather than to
81    say whether the answer was cut.
82    """
83
84    items: list[InstitutionInfo] = Field(default_factory=list)
85    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):
114class InstitutionSuggestion(CtfyModel):
115    """A domain this deployment's people hold proved addresses at.
116
117    ⚠️ **Raw, not registrable.** Where the registrable boundary falls is
118    exactly what nothing here can compute without a Public Suffix List,
119    so ``mails.tsinghua.edu.cn`` and ``tsinghua.edu.cn`` arrive
120    separately and the operator names the suffix they mean — after which
121    the matcher covers both and neither is suggested again.
122    """
123
124    domain: str = ""
125    #: How many accounts hold a proved address there.
126    holders: int = 0
127
128    @computed_field
129    @property
130    def warning(self) -> str:
131        """``"public_suffix"`` where naming this would be too wide.
132
133        The same derivation `InstitutionInfo.warning` publishes, read
134        here *before* the operator commits rather than after — which is
135        the moment it can still change the answer. One producer
136        (`domain_warning`); two models compute it because each holds its
137        own domain, and a shared base would make the *suggestion* carry
138        a stored row's fields.
139        """
140        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
128    @computed_field
129    @property
130    def warning(self) -> str:
131        """``"public_suffix"`` where naming this would be too wide.
132
133        The same derivation `InstitutionInfo.warning` publishes, read
134        here *before* the operator commits rather than after — which is
135        the moment it can still change the answer. One producer
136        (`domain_warning`); two models compute it because each holds its
137        own domain, and a shared base would make the *suggestion* carry
138        a stored row's fields.
139        """
140        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):
143class InstitutionSuggestionPage(CtfyModel):
144    """A window onto the suggestions, with how many there were.
145
146    ⚠️ **The total is not decoration**: this route takes a ``limit``, so
147    a bare list could not tell a complete answer from a truncated one —
148    and "20 of 137 domains nobody has named" is the sentence that tells
149    an operator whether they are nearly done.
150    """
151
152    items: list[InstitutionSuggestion] = Field(default_factory=list)
153    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 IpLocationDatabase(ctfy.core.models.CtfyModel):
12class IpLocationDatabase(CtfyModel):
13    """One ip2region file the platform is serving lookups from."""
14
15    family: Literal["ipv4", "ipv6"]
16    #: Where on the server it was read from — an operator's path, so this
17    #: route is admin-only.
18    path: str = ""
19    size_bytes: int = 0
20    #: When upstream compiled the data (the file's own header), the date that
21    #: says how stale the answers are. ``None`` when the header carries none.
22    built_at: datetime | None = None

One ip2region file the platform is serving lookups from.

family: Literal['ipv4', 'ipv6'] = PydanticUndefined
path: str = ''
size_bytes: int = 0
built_at: datetime.datetime | None = None
class IpLocationStatus(ctfy.core.models.CtfyModel):
25class IpLocationStatus(CtfyModel):
26    """What the admin page shows about the offline IP location data."""
27
28    databases: list[IpLocationDatabase]
29    #: When ``ctfy-admin ip2region update`` last installed files; ``""`` when
30    #: the platform is serving the copy baked into its image.
31    installed_at: str = ""
32    #: ``update`` is a command on the server's host (the files live on its
33    #: volume), so the page names it rather than offering a button.
34    update_command: str = "ctfy-admin ip2region update"

What the admin page shows about the offline IP location data.

databases: list[IpLocationDatabase] = PydanticUndefined
installed_at: str = ''
update_command: str = 'ctfy-admin ip2region update'
class LandingDomain(ctfy.core.models.CtfyModel):
69class LandingDomain(CtfyModel):
70    """One task-type card: its group, how many items it holds, their suites."""
71
72    domain: ChallengeDomain
73    #: The heading the card sits under (网络安全 / 通用能力).
74    group: DomainGroup
75    total: int = 0
76    #: Source buckets, the largest contributor first.
77    suites: list[str] = Field(default_factory=list)

One task-type card: its group, how many items it holds, their suites.

domain: Literal['vuln_ctf', 'enterprise', 'awd', 'security_qa', 'web_ops', 'cs_qa', 'agent_safety'] = PydanticUndefined
group: Literal['security', 'general'] = PydanticUndefined
total: int = 0
suites: list[str] = PydanticUndefined
class LandingEventWindow(ctfy.core.models.CtfyModel):
80class LandingEventWindow(CtfyModel):
81    """One span in which a public competition is taking sign-ups or being
82    played (#3416): ``[starts_at, ends_at)``, ``None`` unbounded.
83
84    ⚠️ **A window, never a verdict.** Whether *now* is inside it is the
85    browser's question (``frontend/src/lib/landingNav.ts``): this response is
86    edge-cached and the same for every caller, so a server-side "an event is
87    on" would be stale the instant registration opened. Derived by
88    ``ctfy.core.competition_attention.attention_windows``.
89    """
90
91    starts_at: AwareDatetime | None = None
92    ends_at: AwareDatetime | None = None

One span in which a public competition is taking sign-ups or being played (#3416): [starts_at, ends_at), None unbounded.

⚠️ A window, never a verdict. Whether now is inside it is the browser's question (frontend/src/lib/landingNav.ts): this response is edge-cached and the same for every caller, so a server-side "an event is on" would be stale the instant registration opened. Derived by ctfy.core.competition_attention.attention_windows.

starts_at: pydantic.types.AwareDatetime | None = None
ends_at: pydantic.types.AwareDatetime | None = None
class LandingFeatured(ctfy.core.models.CtfyModel):
52class LandingFeatured(CtfyModel):
53    """The featured event: a header and one row per track.
54
55    ⚠️ **``status`` and ``next_start_at`` are aggregated here, never in
56    the browser** — "any track taking sign-ups reads 报名中" and "count
57    down to the earliest start" are rules, and two clients composing them
58    are two chances to disagree.
59    """
60
61    name: LocalizedText = Field(default_factory=dict)
62    full_name: LocalizedText = Field(default_factory=dict)
63    organisers: LocalizedText = Field(default_factory=dict)
64    status: FeaturedStatus = "upcoming"
65    next_start_at: AwareDatetime | None = None
66    tracks: list[FeaturedTrack] = Field(default_factory=list)

The featured event: a header and one row per track.

⚠️ status and next_start_at are aggregated here, never in the browser — "any track taking sign-ups reads 报名中" and "count down to the earliest start" are rules, and two clients composing them are two chances to disagree.

name: dict[str, str] = PydanticUndefined
full_name: dict[str, str] = PydanticUndefined
organisers: dict[str, str] = PydanticUndefined
status: Literal['registration_open', 'upcoming', 'running'] = 'upcoming'
next_start_at: pydantic.types.AwareDatetime | None = None
tracks: list[FeaturedTrack] = PydanticUndefined
class LandingPrimarySuite(ctfy.core.models.CtfyModel):
 95class LandingPrimarySuite(CtfyModel):
 96    """The one board the public home page shows (#2341, L5).
 97
 98    The operator-named primary suite's **official** tier on its **pinned**
 99    version (``ctfy.server.primary_suite``). ``version`` is that pin, the
100    full SHA; the page names the suite and the version beside every number
101    so a reader never mistakes it for a platform-wide ranking.
102
103    ``pairs`` is the head of the suite results page's own default table
104    (#2341 B): one row per harness × model, ranked by L8's strict order
105    (``ctfy.core.eval_ranking``), as a signed-out reader of
106    ``/suites/:id?view=results`` sees it -- so a row's ``rank`` here is
107    that page's rank. The counts are the whole board's.
108    """
109
110    id: str
111    title: str = ""
112    title_i18n: LocalizedText = Field(default_factory=dict)
113    version: str = ""
114    challenge_count: int = 0
115    #: Rows on the full board: harness × model combinations.
116    pair_count: int = 0
117    model_count: int = 0
118    harness_count: int = 0
119    last_run_at: AwareDatetime | None = None
120    pairs: list[PairStanding] = Field(default_factory=list)

The one board the public home page shows (#2341, L5).

The operator-named primary suite's official tier on its pinned version (ctfy.server.primary_suite). version is that pin, the full SHA; the page names the suite and the version beside every number so a reader never mistakes it for a platform-wide ranking.

pairs is the head of the suite results page's own default table (#2341 B): one row per harness × model, ranked by L8's strict order (ctfy.core.eval_ranking), as a signed-out reader of /suites/:id?view=results sees it -- so a row's rank here is that page's rank. The counts are the whole board's.

id: str = PydanticUndefined
title: str = ''
title_i18n: dict[str, str] = PydanticUndefined
version: str = ''
challenge_count: int = 0
pair_count: int = 0
model_count: int = 0
harness_count: int = 0
last_run_at: pydantic.types.AwareDatetime | None = None
pairs: list[PairStanding] = PydanticUndefined
class LandingResponse(ctfy.core.models.CtfyModel):
123class LandingResponse(CtfyModel):
124    """``GET /landing`` — public, the same for every caller.
125
126    ⚠️ **``featured`` being ``None`` means "no featured event"** — none
127    configured, every configured one hidden or over — and the page then
128    shows the leaderboard card. It is never a 403/404: a hidden
129    competition named in the setting is simply absent.
130    """
131
132    featured: LandingFeatured | None = None
133    #: ``None`` means the home page shows no board: none named, or the named
134    #: suite is not public or has no pinned version. Never a fallback.
135    primary_suite: LandingPrimarySuite | None = None
136    domains: list[LandingDomain] = Field(default_factory=list)
137    #: Player-written challenges, counted apart from the task types.
138    community: int = 0
139    #: Every window in which a competition a guest can see is taking sign-ups
140    #: or being played, not yet over (#3416). The nav leads with 比赛 while
141    #: the browser's clock is inside one. Empty is "none": never a default.
142    event_windows: list[LandingEventWindow] = Field(default_factory=list)

GET /landing — public, the same for every caller.

⚠️ featured being None means "no featured event" — none configured, every configured one hidden or over — and the page then shows the leaderboard card. It is never a 403/404: a hidden competition named in the setting is simply absent.

featured: LandingFeatured | None = None
primary_suite: LandingPrimarySuite | None = None
domains: list[LandingDomain] = PydanticUndefined
community: int = 0
event_windows: list[LandingEventWindow] = PydanticUndefined
class LastRejection(ctfy.core.models.CtfyModel):
386class LastRejection(CtfyModel):
387    """The team's newest rejection, read from the verdict record (#2515).
388
389    Not the team row's ``review_note``: a later approval with an empty note
390    overwrites that, and the reviewer reading a resubmission or an approved
391    team still wants to know what was wrong the last time and who said so.
392    """
393
394    note: str = ""
395    reviewed_by_name: str = ""
396    reviewed_at: datetime | None = None

The team's newest rejection, read from the verdict record (#2515).

Not the team row's review_note: a later approval with an empty note overwrites that, and the reviewer reading a resubmission or an approved team still wants to know what was wrong the last time and who said so.

note: str = ''
reviewed_by_name: str = ''
reviewed_at: datetime.datetime | None = None
class LeaderboardCell(ctfy.core.models.CtfyModel):
68class LeaderboardCell(CtfyModel):
69    """One row's solve stats within one dimension bucket (e.g. difficulty=hard).
70
71    ⚠️ **The mean over campaigns** (#2614, ``core.eval_board_mean.mean_by_key``),
72    like the row it breaks down: each campaign is scored on the bucket's
73    challenges it ran, and these average those campaigns. Never best-of.
74    """
75
76    key: str = ""
77    attempted: float = 0.0
78    solved: float = 0.0
79    solve_rate: float = 0.0
80    #: How many campaigns ran a challenge in this bucket.
81    campaigns: int = 0

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

⚠️ The mean over campaigns (#2614, core.eval_board_mean.mean_by_key), like the row it breaks down: each campaign is scored on the bucket's challenges it ran, and these average those campaigns. Never best-of.

key: str = ''
attempted: float = 0.0
solved: float = 0.0
solve_rate: float = 0.0
campaigns: int = 0
LimitOffsetPageUpTo200 = <class 'ctfy.server.models.pagination.LimitOffsetPageCustomized'>
LimitOffsetPageUpTo500 = <class 'ctfy.server.models.pagination.LimitOffsetPageCustomized'>
class LinkStartResponse(ctfy.core.models.CtfyModel):
697class LinkStartResponse(CtfyModel):
698    """Returned by ``POST /auth/identities/link/{provider}``.
699
700    The caller is already authenticated via XHR, so we don't 302 them;
701    instead we hand back the authorize URL for the frontend to navigate
702    the browser to.
703    """
704
705    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):
 80class LinkedIdentity(CtfyModel):
 81    id: str
 82    # "github" | "google" (OAuth) or "password" for a local credential.
 83    provider: str
 84    provider_email: str = ""
 85    provider_display_name: str = ""
 86    # Stable third-party account id (GitHub numeric id, Google `sub`);
 87    # empty for the local password credential.
 88    provider_user_id: str = ""
 89    # GitHub `@handle`; empty for Google and password.
 90    provider_login: str = ""
 91    avatar_url: str = ""
 92    created_at: datetime | None = None
 93    last_used_at: datetime | None = None
 94    # What a link-only binding (CARSI) attested about the person's institution
 95    # (#2282). ``affiliation`` is the value as CARSI released it; the owner's
 96    # own settings page is its only reader. ``affiliation_domain`` is the scope
 97    # domain it names and ``affiliation_proved_at`` when it was last released;
 98    # ``""`` / ``None`` = CARSI released no institution.
 99    affiliation: str = ""
100    affiliation_domain: str = ""
101    affiliation_proved_at: datetime | None = None
102    #: The identity type, resolved from ``affiliation`` for the owner's own
103    #: settings page (#2445): ``student`` / ``faculty`` / ``member``, or
104    #: ``""`` when CARSI released no role this platform can name. Composed
105    #: here so no client parses the raw ``role@scope`` value -- and scoped to
106    #: ``affiliation_domain``, so a multi-valued attribute cannot report a
107    #: role belonging to a different school.
108    affiliation_role: AffiliationKind = ""
109    # The catalogue row that domain is, or ``None`` when the operator has not
110    # registered it (#2445). ``None`` is **not** a failure: the binding is
111    # real and ``affiliation_domain`` is then the school's own identifier --
112    # the page draws the domain and says the operator has not catalogued the
113    # name. What it does not do is read the absence as "cannot register":
114    # eligibility is ``verdict_for``'s answer, asked on the competition's
115    # registration page and nowhere else.
116    institution: EligibleInstitution | 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
affiliation: str = ''
affiliation_domain: str = ''
affiliation_proved_at: datetime.datetime | None = None
affiliation_role: Literal['', 'student', 'faculty', 'member'] = ''
institution: EligibleInstitution | None = None
class LlmAccountChoice(ctfy.core.models.CtfyModel):
1149class LlmAccountChoice(CtfyModel):
1150    """One row of the connection picker: a vendor **account**, never a dialect (#3487)."""
1151
1152    id: str
1153    label: str
1154    group: AccountGroup
1155    surfaces: list[LlmAccountSurface] = Field(default_factory=list)
1156    examples: list[str] = Field(default_factory=list)

One row of the connection picker: a vendor account, never a dialect (#3487).

id: str = PydanticUndefined
label: str = PydanticUndefined
group: Literal['international', 'domestic', 'coding_plan'] = PydanticUndefined
surfaces: list[LlmAccountSurface] = PydanticUndefined
examples: list[str] = PydanticUndefined
class LlmAccountSurface(ctfy.core.models.CtfyModel):
1142class LlmAccountSurface(CtfyModel):
1143    """One endpoint of a vendor account, as the picker shows it."""
1144
1145    flavor: Flavor
1146    base_url: str

One endpoint of a vendor account, as the picker shows it.

flavor: ctfy.core.enums.Flavor = PydanticUndefined
base_url: str = PydanticUndefined
class LlmBalanceLine(ctfy.core.models.CtfyModel):
980class LlmBalanceLine(CtfyModel):
981    """One figure a vendor's balance endpoint returned — verbatim, never converted."""
982
983    kind: str = "balance"
984    #: The vendor's own string (``"52.30"``).
985    amount: str
986    #: As the vendor (or its documentation for that host) names it; ``""``
987    #: is "no unit stated", and the page says so rather than guess one.
988    currency: str = ""

One figure a vendor's balance endpoint returned — verbatim, never converted.

kind: str = 'balance'
amount: str = PydanticUndefined
currency: str = ''
class LlmBalanceRefresh(ctfy.core.models.CtfyModel):
1031class LlmBalanceRefresh(CtfyModel):
1032    """Read one connection's balance: ``force`` is 刷新; without it, a page
1033    view, answered from the last read when it is recent."""
1034
1035    id: str = Field(min_length=1, max_length=600)
1036    force: bool = False

Read one connection's balance: force is 刷新; without it, a page view, answered from the last read when it is recent.

id: str = PydanticUndefined
force: bool = False
class LlmBudgetPage(ctfy.core.models.CtfyModel):
942class LlmBudgetPage(CtfyModel):
943    """The organiser's LLM-spend roster.
944
945    ``gateway_configured`` is on the envelope for the reason
946    ``webhook_configured`` is on the email-suppression list: an empty
947    table otherwise reads as "nobody has spent anything" when it means
948    "this deployment enforces no budgets at all", and those call for
949    opposite actions.
950    """
951
952    items: list[LlmBudgetRow]
953    total: int
954    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):
656class LlmBudgetResetInfo(CtfyModel):
657    """Response from the LLM-budget reset endpoint.
658
659    ⚠️ ``outcome`` is the point of the model. A reset touches two stores
660    and can legitimately land as *nothing to reset*; reporting only
661    ``200`` would make "the team has a fresh allowance" and "the gateway
662    is unreachable so nothing changed" the same reply, which is exactly
663    the failure the seam is written to avoid.
664    """
665
666    team_id: str
667    competition_id: str
668    challenge_id: str
669    outcome: str
670    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):
646class LlmBudgetResetRequest(CtfyModel):
647    """Request body for ``POST /admin/teams/.../llm-budget/.../reset``.
648
649    ``reason`` is free-form audit metadata, capped like its
650    question-attempt twin so it round-trips through the SSE frame.
651    """
652
653    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):
899class LlmBudgetRow(CtfyModel):
900    """One team's LLM spend on one challenge.
901
902    ⚠️ ``budget_tokens`` is the cap **as configured right now**, not the
903    cap that applied when the spend happened: it is one deployment-wide
904    setting the operator can retune mid-event. So a row can read
905    ``spent 90k of 200k`` for a team that really was refused at 50k, and
906    the honest place to see what happened *then* is the
907    ``llm_quota_exhausted`` activity row, which carries the cap it
908    crossed. Reporting a per-row historical cap would mean storing one,
909    which is a column that exists only to be believed.
910
911    ``exhausted`` is derived from the pair rather than stored, for the
912    same reason: it is a statement about the two numbers on this row and
913    must not disagree with them.
914    """
915
916    team_id: str
917    team_name: str = ""
918    competition_id: str
919    challenge_id: str
920    spent_tokens: int
921    budget_tokens: int
922    #: Whether this team still holds a live grant for this challenge.
923    #: A budget outlives the box that spent it — the key is
924    #: ``(team, competition, challenge)`` precisely so a fresh instance
925    #: does not refill — so a row with no live grant is the normal
926    #: shape after a box stops, not an orphan.
927    live: bool = False
928
929    @computed_field
930    @property
931    def exhausted(self) -> bool:
932        """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified``
933        which is deliberately a plain ``@property``. Opposite reasons,
934        and the distinction is the model's kind: that one is *state*, so
935        ``model_dump`` feeds ``to_orm`` and every dumped key has to be a
936        column; this is a *wire* model whose whole purpose is to reach a
937        client, and a plain property simply never leaves the process.
938        """
939        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
929    @computed_field
930    @property
931    def exhausted(self) -> bool:
932        """⚠️ A **`computed_field`**, unlike ``TeamState.is_disqualified``
933        which is deliberately a plain ``@property``. Opposite reasons,
934        and the distinction is the model's kind: that one is *state*, so
935        ``model_dump`` feeds ``to_orm`` and every dumped key has to be a
936        column; this is a *wire* model whose whole purpose is to reach a
937        client, and a plain property simply never leaves the process.
938        """
939        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):
2428class LlmCallBody(CtfyModel):
2429    """One call's archived conversation.
2430
2431    ⚠️ **Attacker-authored text.** Every byte of the prompt half is
2432    written by whoever is attacking the challenge. Rendered as plain
2433    text, never markdown, never HTML.
2434    """
2435
2436    id: str = ""
2437    #: The rewritten request the gateway actually sent, as JSON text.
2438    request: str = ""
2439    #: The completion, reassembled from the stream where it was one.
2440    response: str = ""
2441    truncated: bool = False
2442    #: ``False`` when retention has reclaimed the object; the row stays
2443    #: so the page can say the call happened.
2444    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):
1551class LlmCallPage(CtfyModel):
1552    """A page of calls, plus what the reader needs to trust it."""
1553
1554    items: list[LlmCallRow] = Field(default_factory=list)
1555    total: int = 0
1556    #: ⚠️ Distinguishes "no calls" from "this deployment keeps no index",
1557    #: which look identical and call for opposite actions. Same argument
1558    #: as ``LlmBudgetPage.gateway_configured``.
1559    indexing: bool = True
1560    #: When the built-in price list was true. A money figure with no date
1561    #: is the one somebody trusts a year later.
1562    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):
1444class LlmCallRow(CtfyModel):
1445    """One relayed model call, as a page reads it.
1446
1447    Wire naming: ``id`` is this row's own id, everything ``<entity>_id``
1448    is a foreign key. The frontend reads these verbatim.
1449
1450    ⚠️ ``usage_known`` rides along so the page can tell "0 tokens" from
1451    "nobody knows" — a distinction ``extract_usage`` preserves on purpose
1452    (the torn-stream rule charges rather than refunds) and which a table
1453    rendering both as ``0`` would destroy at the last metre.
1454
1455    ⚠️ ``estimated_usd`` is ``None`` for a model no price list knows, and
1456    the page must show a dash rather than ``$0.00``. It is a
1457    ``computed_field`` because this is a *wire* model — see the
1458    ``LlmBudgetRow.exhausted`` rule.
1459    """
1460
1461    id: str = ""
1462    at_ts: float = 0.0
1463    # ⚠️ ``str``, not ``Principal`` — the wire projection of a column
1464    # that can hold a value this platform does not model (see the note
1465    # on ``LlmCallState.principal``). Narrowing here moves the failure
1466    # from "the drawer shows an unfamiliar word" to "the admin page
1467    # 500s", which is the worse of the two by a distance.
1468    principal: str = "instance"
1469    competition_id: str = ""
1470    team_id: str = ""
1471    challenge_id: str = ""
1472    instance_id: str = ""
1473    flavor: FlavorOrUnset = ""
1474    model: str = ""
1475    status: int = 0
1476    stream: bool = False
1477    aborted: bool = False
1478    truncated: bool = False
1479    stop_reason: str = ""
1480    usage_known: bool = False
1481    input_tokens: int = 0
1482    output_tokens: int = 0
1483    cache_write_tokens: int = 0
1484    cache_read_tokens: int = 0
1485    upstream_ms: int = 0
1486    queue_ms: int = 0
1487    attempts: int = 1
1488    key_id: str = ""
1489    request_bytes: int = 0
1490    #: Whether the body is still in storage. The *key* itself never
1491    #: reaches a player — it is an address in the operator's bucket.
1492    has_body: bool = False
1493    #: What the owning squad is called, beside the ``team_id`` above.
1494    #: ``""`` on the player's own run page, where the owner would only
1495    #: ever be themselves.
1496    #:
1497    #: ⚠️ **This was ``owner_label``, and it was fed ``row.team_id``
1498    #: verbatim for its whole life** — a uuid in the field two admin
1499    #: pages render as their Owner column, which drew
1500    #: ``3f9a1b2c-…`` under a heading promising a name.
1501    #: `MyEvalRunRow.owner_label`'s recorded defect on the neighbouring
1502    #: model, one composer along.
1503    #:
1504    #: ⚠️ **``team_name``, not ``owner_display_name``, and the earlier
1505    #: refusal got half of its own reason wrong.** It argued the suffix
1506    #: "would enlist a squad's identity in a person-identity scan" —
1507    #: measured, ``test_anonymity_doors.IDENTITY_FIELDS`` has carried
1508    #: ``team_name`` and ``first_blood_team_name`` since it was written,
1509    #: so squad identity was never outside that scan and being in it is
1510    #: the point: it is what forces the door question to be answered.
1511    #: What survives of the refusal is the *producer* half —
1512    #: `entity_labels` rules on what a **person** is called and
1513    #: `LlmCallState` carries no user id — so the label comes from
1514    #: `ctfy.server.team_labels.operator_team_labels` and the field
1515    #: takes the name `InstanceInfo` already uses for this exact fact.
1516    #:
1517    #: ⚠️ **Never the id as a fallback** (`InstanceInfo.team_name`'s
1518    #: contract). ``""`` beside a set id is the third state, and here it
1519    #: has two readings the id itself separates: a uuid means the squad
1520    #: is gone or never named itself, while ``__platform__`` is
1521    #: `PLATFORM_TEAM_ID`, the sentinel chosen to read as itself.
1522    team_name: str = ""
1523    #: Which conversation the call continues, as a content-free hash
1524    #: (``core/llm_session.py``, #2401); ``""`` on rows drained before it.
1525    #: The run page groups by it; it is safe beside the team's metadata
1526    #: because nothing about the body can be read back out of it.
1527    session_key: str = ""
1528    #: Messages the request carried — the call's place in that thread.
1529    turn_index: int = 0
1530
1531    @computed_field
1532    @property
1533    def estimated_usd(self) -> float | None:
1534        """⚠️ ``None``, never ``0.0``, for an unpriced model."""
1535        from ctfy.core.llm_pricing import estimate_usd
1536        from ctfy.core.llm_spend import Spend
1537
1538        if not self.usage_known:
1539            return None
1540        return estimate_usd(
1541            self.model,
1542            Spend(
1543                input_tokens=self.input_tokens,
1544                output_tokens=self.output_tokens,
1545                cache_write_tokens=self.cache_write_tokens,
1546                cache_read_tokens=self.cache_read_tokens,
1547            ),
1548        )

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 = ''
session_key: str = ''
turn_index: int = 0
estimated_usd: float | None
1531    @computed_field
1532    @property
1533    def estimated_usd(self) -> float | None:
1534        """⚠️ ``None``, never ``0.0``, for an unpriced model."""
1535        from ctfy.core.llm_pricing import estimate_usd
1536        from ctfy.core.llm_spend import Spend
1537
1538        if not self.usage_known:
1539            return None
1540        return estimate_usd(
1541            self.model,
1542            Spend(
1543                input_tokens=self.input_tokens,
1544                output_tokens=self.output_tokens,
1545                cache_write_tokens=self.cache_write_tokens,
1546                cache_read_tokens=self.cache_read_tokens,
1547            ),
1548        )

⚠️ None, never 0.0, for an unpriced model.

class LlmConnectionBalance(ctfy.core.models.CtfyModel):
 991class LlmConnectionBalance(CtfyModel):
 992    """The vendor account's balance, as read with this connection's key (#3487).
 993
 994    ⚠️ **Absent is not zero**: ``not_offered`` (the vendor has no endpoint a
 995    key may read) is its own state and the page says so in words.
 996    """
 997
 998    #: ``core.llm_balance.balance_state``: what the card says.
 999    state: BalanceState = "not_offered"
1000    #: The last **good** read, kept across a failed one.
1001    lines: list[LlmBalanceLine] = Field(default_factory=list)
1002    #: When the last good read was made; ``""`` never.
1003    ok_at: str = ""
1004    #: The last attempt, good or not, and its code.
1005    read_at: str = ""
1006    code: str = ""
1007    #: Below ``LOW_BALANCE`` on a successful read — a warning, never a block.
1008    low: bool = False

The vendor account's balance, as read with this connection's key (#3487).

⚠️ Absent is not zero: not_offered (the vendor has no endpoint a key may read) is its own state and the page says so in words.

state: Literal['not_offered', 'never', 'ok', 'unauthorized', 'failed'] = 'not_offered'
lines: list[LlmBalanceLine] = PydanticUndefined
ok_at: str = ''
read_at: str = ''
code: str = ''
low: bool = False
class LlmConnectionRef(ctfy.core.models.CtfyModel):
1171class LlmConnectionRef(CtfyModel):
1172    """Names one of the caller's connections. In a body, never a path: the id
1173    carries ``/`` (``probe_key_by_body``'s reason)."""
1174
1175    id: str = Field(min_length=1, max_length=600)

Names one of the caller's connections. In a body, never a path: the id carries / (probe_key_by_body's reason).

id: str = PydanticUndefined
class LlmConnectionUsage(ctfy.core.models.CtfyModel):
1061class LlmConnectionUsage(CtfyModel):
1062    """One connection's usage block (#3487)."""
1063
1064    connection_id: str
1065    totals: LlmUsageTotals = Field(default_factory=LlmUsageTotals)
1066    #: Month-to-date in the cap's two units (UTC calendar month).
1067    month_usd: float = 0.0
1068    month_tokens: int = 0
1069    #: Either unit of a set cap is reached: the gateway refuses new calls.
1070    cap_reached: bool = False

One connection's usage block (#3487).

connection_id: str = PydanticUndefined
totals: LlmUsageTotals = PydanticUndefined
month_usd: float = 0.0
month_tokens: int = 0
cap_reached: bool = False
class LlmLivePage(ctfy.core.models.CtfyModel):
1698class LlmLivePage(CtfyModel):
1699    """``GET /eval/runs/{run_id}/llm-live``: the model's output after a cursor.
1700
1701    Each delta is what the gateway relayed, grouped by ``call``: a ``start``
1702    (model, whether it streams, the agent's newest message), output parts
1703    (``text`` / ``thinking`` / ``tool`` / ``tool_args``) and an ``end`` naming
1704    the archived ``call_id``. A non-streamed call has no parts until its
1705    ``end`` is near: its whole answer arrives at once.
1706
1707    ⚠️ **Scrubbed before it was held**, like the console: the run's own
1708    credentials, every answer the platform holds for its challenge, every
1709    credential shape and every ``FLAG{…}`` shape — per delta, so a secret
1710    split across two deltas can survive here; the archived call (the same
1711    readers) is the record either way. Rendered as plain text, never markup.
1712    """
1713
1714    run_id: str
1715    state: LlmLiveState
1716    reason: LlmLiveUnavailable = ""
1717    #: Oldest first. ``seq`` is the cursor; a jump in it is a gap.
1718    deltas: list[LiveDelta] = Field(default_factory=list)
1719    #: The oldest delta still held, ``0`` when none is.
1720    first_seq: int = 0
1721    #: The newest delta numbered so far. Ask again with ``after=last_seq``.
1722    last_seq: int = 0
1723    #: More deltas after the returned ones are already held.
1724    has_more: bool = False

GET /eval/runs/{run_id}/llm-live: the model's output after a cursor.

Each delta is what the gateway relayed, grouped by call: a start (model, whether it streams, the agent's newest message), output parts (text / thinking / tool / tool_args) and an end naming the archived call_id. A non-streamed call has no parts until its end is near: its whole answer arrives at once.

⚠️ Scrubbed before it was held, like the console: the run's own credentials, every answer the platform holds for its challenge, every credential shape and every FLAG{…} shape — per delta, so a secret split across two deltas can survive here; the archived call (the same readers) is the record either way. Rendered as plain text, never markup.

run_id: str = PydanticUndefined
state: Literal['waiting', 'live', 'ended', 'unavailable'] = PydanticUndefined
reason: Literal['', 'no_gateway', 'gateway_unsupported', 'not_kept'] = ''
deltas: list[ctfy.core.llm_live.LiveDelta] = PydanticUndefined
first_seq: int = 0
last_seq: int = 0
has_more: bool = False
class LlmModelOption(ctfy.core.models.CtfyModel):
763class LlmModelOption(CtfyModel):
764    """A model an entrant may pick, as an entrant may see it.
765
766    ⚠️ **A projection, not :class:`LlmModelState`.** The registry row
767    carries the operator's own scratchpad, who reviewed it, and the
768    addressing that says where the deployment's credential is spent
769    (``base_url``, ``provider_model``). None of that is an entrant's
770    business — and a route returning the state model would publish every
771    one of those the first time somebody added a field to it.
772
773    ⚠️ ``flavor`` is load-bearing rather than descriptive: a
774    bring-your-own key is stored per flavor, so "do I already have a key
775    for this model" is a question the page can only answer if the model
776    says which flavor it is.
777    """
778
779    #: Only this caller's saved compatible connections, never private provider addresses.
780    compatible_connection_ids: list[str] = Field(default_factory=list)
781    id: str = ""
782    display_name: str = ""
783    flavor: FlavorOrUnset = ""
784    vendor: str = ""
785    #: USD per 1,000,000 tokens. Carried so an entrant can weigh a
786    #: choice against the allowance they hold; ``0`` means unpriced, the
787    #: same reading the leaderboard gives it.
788    input_price_per_mtok: float = 0.0
789    output_price_per_mtok: float = 0.0
790    #: #3473: the wire surfaces this model is known to be served on —
791    #: ``core.llm_catalog.model_surfaces``, the rule the gateway routes by.
792    #: An address the catalog does not know lists only its own dialect's.
793    surfaces: list[WireSurface] = Field(default_factory=list)
794    #: Whether ``surfaces`` is the whole answer: true where the catalog knows
795    #: the address, so a surface missing from it is refused before a run.
796    #: False is a floor — the start then refuses nothing on dialect.
797    surfaces_complete: bool = False
798    #: #3476: no LLM gateway provider can serve this model, so the platform
799    #: cannot pay for a run on it (``core.llm_fleet``) — a run on the
800    #: caller's own key still can. ⚠️ ``False`` unless that absence is
801    #: known: a default must never be a new claim, and the wizard reads
802    #: only ``=== true`` as blocked.
803    no_gateway_provider: bool = False

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.

compatible_connection_ids: list[str] = PydanticUndefined
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
surfaces: list[typing.Literal['anthropic_messages', 'openai_chat', 'openai_responses']] = PydanticUndefined
surfaces_complete: bool = False
no_gateway_provider: bool = False
class LlmMoney(ctfy.core.models.CtfyModel):
1039class LlmMoney(CtfyModel):
1040    """An estimated amount in one currency; never summed across two."""
1041
1042    currency: str
1043    amount: float

An estimated amount in one currency; never summed across two.

currency: str = PydanticUndefined
amount: float = PydanticUndefined
class LlmProviderChoice(ctfy.core.models.CtfyModel):
1178class LlmProviderChoice(CtfyModel):
1179    """One upstream a user may point their own key at.
1180
1181    ⚠️ **Served, not shipped in the client.** The operator can replace
1182    the set, and a client with its own copy would offer providers the
1183    server refuses — the user then does exactly what the page said and
1184    is turned away.
1185
1186    ⚠️ **``flavor`` rides with the address** because a provider is both.
1187    DeepSeek, Moonshot and OpenAI all speak ``openai``, so a form asking
1188    the two separately contains a wrong combination, and the wrong
1189    combination stores a key where nothing will look for it.
1190    """
1191
1192    id: str
1193    label: str
1194    #: ⚠️ Required and never empty: an entry in this catalog *is* a
1195    #: provider, so a dialect-less one would be an address the form
1196    #: could offer and no credential could be stored against.
1197    flavor: Flavor
1198    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):
876class LlmProviderCreate(CtfyModel):
877    """A provider as an operator types it.
878
879    ⚠️ Validated **here**, not only at the gateway. ``POST /keys/sync``
880    reports a ``rejected`` list whose whole meaning is "the platform and
881    the gateway have drifted, somebody look" — which is only true if a
882    row the gateway would refuse could not have been stored. Without
883    this, a refusal there would be the ordinary result of a typo and
884    nobody would read it.
885    """
886
887    key_id: str = Field(min_length=1, max_length=64)
888    api_key: str = Field(min_length=1, max_length=512)
889    #: ⚠️ **The type is the check.** This was a ``str`` with a
890    #: ``field_validator`` reaching into the gateway package for a tuple
891    #: of two literals — one of three hand-rolled copies of a set the
892    #: enum already closes. Declaring it here refuses the same typo at
893    #: the same boundary with the same 422, and puts the allowed values
894    #: in the OpenAPI schema, so the browser's own types carry them too.
895    flavor: Flavor
896    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):
673class LlmProviderInfo(CtfyModel):
674    """One upstream credential as an operator may see it.
675
676    ⚠️ **No ``api_key`` field, and that absence is the model.** The
677    column is sealed precisely so the secret cannot be read back, and a
678    wire model carrying it would undo that for every caller at once. A
679    last-four ``hint`` is included because an operator rotating one of
680    several keys has to tell them apart, and four characters of a
681    100-bit secret identify without reconstructing — the alternative is
682    a table of opaque ids where picking the wrong row revokes the wrong
683    vendor.
684    """
685
686    key_id: str
687    flavor: FlavorOrUnset
688    base_url: str = ""
689    #: Last four characters of the secret, or empty for one too short to
690    #: hint at without giving away a meaningful share of it.
691    hint: str = ""
692    added_by: str = ""
693    added_at: str = ""
694    #: Whether the platform's last key push carried this row to the
695    #: gateway: ``synced`` / ``pending`` / ``rejected``, or ``""`` when
696    #: there is no gateway to push to.
697    #:
698    #: ⚠️ **A record of the last push, never a live reading**, and the
699    #: page's copy has to say so. The gateway persists nothing, so it can
700    #: restart and lose the whole fleet a second after acknowledging it;
701    #: the platform hears on the next tick. Deriving it any other way
702    #: would mean asking the gateway from a browser — a second source for
703    #: a fact only the pushing party knows the outcome of.
704    #:
705    #: ⚠️ Defaults to ``""``, the *not-a-new-claim* direction. Defaulting
706    #: to ``synced`` would make a producer that forgets assert every
707    #: credential is live; same rule as ``ScoreboardEntry.review_status``
708    #: and ``SubmissionResponse.ranked``.
709    sync_state: str = ""
710    #: Whether this secret is also an ``CTFY_EVAL_*`` key — one upstream
711    #: account serving both the players' gateway and the eval harness,
712    #: which spends **directly**. The gateway's ledger then calibrates
713    #: against capacity somebody else is consuming: it under-counts, the
714    #: limiter over-admits, and the first observable is a 429 storm or a
715    #: bill.
716    #:
717    #: ⚠️ Derived on the read, never stored — a column would need
718    #: clearing when either side is rotated, and a missed write reads
719    #: exactly like "these are different accounts". Defaults ``False``
720    #: for the usual reason: the default must not be a new claim.
721    shares_eval_account: bool = False
722    #: How many *user* keys sit at this row's ``(flavor, base_url)`` and
723    #: therefore spend instead of it.
724    #:
725    #: ⚠️ **Not a warning about the credential — a warning about the
726    #: page.** `execute_one_eval_run` reads
727    #: ``get_user_llm_key(owner, flavor, model.base_url)`` first and
728    #: never consults the fleet row when one answers, so for those runs
729    #: this row's green probe is about a credential nothing spent.
730    #: Measured: an operator read that tick while a run burned 77
731    #: minutes against an expired BYOK key at the same address, and
732    #: nothing on either page connected the two.
733    #:
734    #: ⚠️ A count, never the owners. Naming them would make this page
735    #: report who holds a credential with which provider — the same
736    #: disclosure `DELETE /eval/llm-keys/{flavor}`'s 404 exists to
737    #: avoid. The number is what changes the reading; the identities
738    #: are somebody else's business.
739    #:
740    #: ⚠️ Derived on the read, like ``shares_eval_account`` above and
741    #: for its reason: a stored count needs clearing whenever any user
742    #: adds or drops a key, and a missed write reads exactly like
743    #: "nothing shadows this".
744    shadowed_by: int = 0
745    #: #3476: the active registered models a platform-funded run would be
746    #: relayed on this row for — its own seat, or an account twin the
747    #: gateway derives from it (``core.llm_fleet.row_reaches``). Derived on
748    #: the read: "I registered the model but not the key" is visible here
749    #: rather than in a failed run.
750    serves_models: list[str] = Field(default_factory=list)

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
serves_models: list[str] = PydanticUndefined
class LlmProviderPage(ctfy.core.models.CtfyModel):
796class LlmProviderPage(CtfyModel):
797    items: list[LlmProviderInfo] = Field(default_factory=list)
798    total: int = 0
799    #: Whether this deployment has a gateway at all. ⚠️ An empty table
800    #: is ambiguous and must say which emptiness it is: "no providers
801    #: configured" and "this platform pushes to nothing" render
802    #: identically and call for opposite actions. Same argument as
803    #: ``webhook_configured`` on the email-suppression list.
804    gateway_configured: bool = False
805    #: Whether an upstream key can be sealed at rest here at all, i.e.
806    #: whether ``POST`` can succeed.
807    #:
808    #: ⚠️ **A precondition, and the browser could not see it.** The write
809    #: route pre-checks ``pii.is_enabled()`` and answers ``503
810    #: sealing_not_configured`` with a message naming the variable to
811    #: set — but nothing on the read said so, so the only way to find out
812    #: was to paste a *real* credential into a form that was always going
813    #: to refuse it. The wizard asks before it opens.
814    #:
815    #: ⚠️ Defaults ``False`` because a default must never be a new claim
816    #: (same rule as ``gateway_configured`` above and
817    #: ``SubmissionResponse.ranked``) — and the *client* reads
818    #: ``=== false`` rather than ``!``, so an older server, which sends
819    #: no field at all, does not have its add button taken away. Gates
820    #: fail open; only a server that positively says "I cannot seal"
821    #: closes this one.
822    sealing_configured: bool = False
823    #: #3476: active registered models **no** row here can serve, so the
824    #: platform cannot pay for a run on them — every entry door refuses
825    #: them (``eval_model_has_no_gateway_provider``). A run on somebody's
826    #: own key is unaffected. ``[]`` claims nothing beyond that.
827    unserved_models: list[str] = 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.

items: list[LlmProviderInfo] = PydanticUndefined
total: int = 0
gateway_configured: bool = False
sealing_configured: bool = False
unserved_models: list[str] = PydanticUndefined
class LlmProviderProbe(ctfy.core.models.CtfyModel):
830class LlmProviderProbe(CtfyModel):
831    """What one probe of one credential established.
832
833    ⚠️ **A code, never a boolean.** Four refusals mean four different
834    fields to go and fix — the key, the address, the model, the account —
835    and one red cross reports them identically. The page renders the code
836    and shows ``detail`` beside it, which is the only thing separating
837    the two ambiguous causes of ``bad_request``.
838    """
839
840    key_id: str = ""
841    ok: bool = False
842    #: ``ok`` · ``auth_failed`` · ``quota_exceeded`` · ``unreachable`` ·
843    #: ``bad_request`` — the provider's verdict; plus ``no_gateway`` ·
844    #: ``gateway_unreachable`` · ``not_synced``, which are facts about
845    #: *this platform's* wiring rather than about the credential.
846    code: str = ""
847    #: The status the provider actually sent, kept verbatim: the code is
848    #: a summary and a bug report wants the original. 0 when nothing
849    #: answered.
850    status: int = 0
851    #: The provider's own message, bounded. Never our own paraphrase —
852    #: it is what names the wrong model or the wrong host.
853    detail: str = ""
854    #: What the model actually said, bounded by the gateway.
855    #:
856    #: ⚠️ **This is what makes the verdict about the model rather than
857    #: about the endpoint.** A 200 with an empty completion — a misrouted
858    #: gateway, a content filter, a proxy answering for a provider it
859    #: never reached — is indistinguishable from a working credential by
860    #: status code alone. Showing the answer is how an operator tells
861    #: those apart, and empty is therefore a *result* rather than a
862    #: missing field.
863    #:
864    #: ⚠️ **Reported, never graded.** A check on the text would fail on
865    #: correct configuration — a weak model, a stray "Sure!", a reply in
866    #: another language — and this repository's own record is that such a
867    #: check is the one that gets deleted.
868    reply: str = ""
869    #: ⚠️ The offline stub answers **ok**, so a green tick there would be
870    #: the dangerous result: an operator carries it into an event whose
871    #: every answer is invented. Defaults ``False`` for the usual reason
872    #: — the default must not be a new claim.
873    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 LlmRunUsage(ctfy.core.models.CtfyModel):
1073class LlmRunUsage(CtfyModel):
1074    """One run's calls on one connection, by model."""
1075
1076    run_id: str
1077    connection_id: str
1078    model: str = ""
1079    input_tokens: int = 0
1080    output_tokens: int = 0
1081    calls: int = 0
1082    cost: LlmMoney | None = None
1083    last_at: str = ""

One run's calls on one connection, by model.

run_id: str = PydanticUndefined
connection_id: str = PydanticUndefined
model: str = ''
input_tokens: int = 0
output_tokens: int = 0
calls: int = 0
cost: LlmMoney | None = None
last_at: str = ''
class LlmSpendCap(ctfy.core.models.CtfyModel):
1011class LlmSpendCap(CtfyModel):
1012    """A connection's optional monthly cap (UTC calendar month), enforced by the gateway."""
1013
1014    #: Dollars of estimated spend on priced models; ``0`` none.
1015    usd: float = 0.0
1016    #: Tokens on every call, priced or not; ``0`` none.
1017    tokens: int = 0
1018    #: The currency a money cap is offered in for this vendor, ``""`` when
1019    #: none of its models has a published price here (tokens only).
1020    money_currency: str = ""

A connection's optional monthly cap (UTC calendar month), enforced by the gateway.

usd: float = 0.0
tokens: int = 0
money_currency: str = ''
class LlmSpendCapUpdate(ctfy.core.models.CtfyModel):
1023class LlmSpendCapUpdate(CtfyModel):
1024    """Set or clear (zeros) one connection's monthly cap."""
1025
1026    id: str = Field(min_length=1, max_length=600)
1027    usd: float = Field(default=0.0, ge=0, le=1_000_000)
1028    tokens: int = Field(default=0, ge=0, le=1_000_000_000_000)

Set or clear (zeros) one connection's monthly cap.

id: str = PydanticUndefined
usd: float = 0.0
tokens: int = 0
class LlmSurfaceStatus(ctfy.core.models.CtfyModel):
959class LlmSurfaceStatus(CtfyModel):
960    """One surface (dialect endpoint) of a connection, and what it last answered (#3487)."""
961
962    flavor: Flavor
963    #: The endpoint, as the catalog spells it.
964    base_url: str
965    #: The last check's code (``MyLlmKeyProbe.code``'s vocabulary); ``""``
966    #: is *never checked* — and both ``""`` or both set, never one.
967    code: str = ""
968    status: int = 0
969    detail: str = ""
970    checked_at: str = ""
971    stub: bool = False
972    #: Composed server-side from ``core.llm_connection.surface_refuses``:
973    #: an entry whose agent calls this surface is refused at submit.
974    refuses: bool = False
975    #: ``core.llm_connection.surface_verdict`` of ``code``: what the row
976    #: says to a person, so the browser branches on no probe code.
977    verdict: SurfaceVerdict = "never"

One surface (dialect endpoint) of a connection, and what it last answered (#3487).

flavor: ctfy.core.enums.Flavor = PydanticUndefined
base_url: str = PydanticUndefined
code: str = ''
status: int = 0
detail: str = ''
checked_at: str = ''
stub: bool = False
refuses: bool = False
verdict: Literal['pass', 'model', 'fail', 'platform', 'never'] = 'never'
class LlmUsagePage(ctfy.core.models.CtfyModel):
1086class LlmUsagePage(CtfyModel):
1087    """Usage and estimated spend across the caller's connections (#3487)."""
1088
1089    window_days: int
1090    since: str
1091    #: The UTC calendar month a cap counts (``YYYY-MM``).
1092    month: str
1093    #: When the price table was read — beside every estimate.
1094    rates_as_of: str
1095    connections: list[LlmConnectionUsage] = Field(default_factory=list)
1096    total: LlmUsageTotals = Field(default_factory=LlmUsageTotals)
1097    #: The newest runs across every connection, at most ``runs_limit``;
1098    #: ``total.runs`` is the whole count.
1099    runs: list[LlmRunUsage] = Field(default_factory=list)
1100    runs_limit: int = 0

Usage and estimated spend across the caller's connections (#3487).

window_days: int = PydanticUndefined
since: str = PydanticUndefined
month: str = PydanticUndefined
rates_as_of: str = PydanticUndefined
connections: list[LlmConnectionUsage] = PydanticUndefined
total: LlmUsageTotals = PydanticUndefined
runs: list[LlmRunUsage] = PydanticUndefined
runs_limit: int = 0
class LlmUsageTotals(ctfy.core.models.CtfyModel):
1046class LlmUsageTotals(CtfyModel):
1047    """Tokens and estimated cost over a window — exact counts, estimated money."""
1048
1049    #: The whole prompt: uncached input plus cache reads and writes.
1050    input_tokens: int = 0
1051    output_tokens: int = 0
1052    calls: int = 0
1053    runs: int = 0
1054    #: Per currency, over priced calls only; ``[]`` when none was priced
1055    #: (shown as ``—``, never ``0``).
1056    cost: list[LlmMoney] = Field(default_factory=list)
1057    #: Tokens of calls whose model has no published price on the account.
1058    unpriced_tokens: int = 0

Tokens and estimated cost over a window — exact counts, estimated money.

input_tokens: int = 0
output_tokens: int = 0
calls: int = 0
runs: int = 0
cost: list[LlmMoney] = PydanticUndefined
unpriced_tokens: int = 0
class LoginRequest(ctfy.core.models.CtfyModel):
136class LoginRequest(CtfyModel):
137    email: EmailStr
138    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 MachineAssignOutcome(ctfy.core.models.CtfyModel):
123class MachineAssignOutcome(CtfyModel):
124    machine_id: str
125    team_id: str
126    outcome: AssignOutcome

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.

machine_id: str = PydanticUndefined
team_id: str = PydanticUndefined
outcome: Literal['assigned', 'replaced', 'machine_unavailable', 'team_has_machine'] = PydanticUndefined
class MachineAssignRequest(ctfy.core.models.CtfyModel):
116class MachineAssignRequest(CtfyModel):
117    team_id: str
118    #: Allow the team's current machine to be replaced (it is retired and its
119    #: secrets are erased). Without it a team that holds one is refused.
120    replace: 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
replace: bool = False
class MachineAssignResult(ctfy.core.models.CtfyModel):
101class MachineAssignResult(CtfyModel):
102    assigned: int = 0
103    unresolved_pins: list[UnresolvedPin] = Field(default_factory=list)
104    surplus_machines: int = 0
105    teams_without: 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.

assigned: int = 0
unresolved_pins: list[UnresolvedPin] = PydanticUndefined
surplus_machines: int = 0
teams_without: int = 0
class MachineImportError(ctfy.core.models.CtfyModel):
88class MachineImportError(CtfyModel):
89    """One refused row. A line number and a code; never the row's content."""
90
91    line: int
92    code: ImportErrorCode
93    column: str = ""

One refused row. A line number and a code; never the row's content.

line: int = PydanticUndefined
code: Literal['too_large', 'too_many_rows', 'no_header', 'unknown_column', 'missing_column', 'bad_csv', 'host_missing', 'host_invalid', 'port_invalid', 'username_missing', 'credential_missing', 'field_too_long', 'duplicate_in_file'] = PydanticUndefined
column: str = ''
class MachineImportRequest(ctfy.core.models.CtfyModel):
76class MachineImportRequest(CtfyModel):
77    """The organiser's CSV, as text (``host,port,username,password,private_key,note,team``).
78
79    ⚠️ ``csv`` is a secret in transit: it is never logged, echoed or stored
80    as text -- the rows are sealed one by one on the way in.
81    """
82
83    csv: str = Field(repr=False)
84    #: Run the auto-assign right after a clean import.
85    auto_assign: bool = False

The organiser's CSV, as text (host,port,username,password,private_key,note,team).

⚠️ csv is a secret in transit: it is never logged, echoed or stored as text -- the rows are sealed one by one on the way in.

csv: str = PydanticUndefined
auto_assign: bool = False
class MachineImportResult(ctfy.core.models.CtfyModel):
108class MachineImportResult(CtfyModel):
109    imported: int = 0
110    #: Rows already present (same host, port and username, still live).
111    skipped_existing: int = 0
112    errors: list[MachineImportError] = Field(default_factory=list)
113    assignment: MachineAssignResult | 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.

imported: int = 0
skipped_existing: int = 0
errors: list[MachineImportError] = PydanticUndefined
assignment: MachineAssignResult | None = None
class MachineNotifyRequest(ctfy.core.models.CtfyModel):
129class MachineNotifyRequest(CtfyModel):
130    #: Also mail each captain (a link to the team page, never the login).
131    #: The in-app card appears either way.
132    mail: bool = True

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.

mail: bool = True
class MachineNotifyResult(ctfy.core.models.CtfyModel):
135class MachineNotifyResult(CtfyModel):
136    #: Captains newly told (machines stamped notified).
137    captains: int = 0
138    mailed: 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.

captains: int = 0
mailed: int = 0
class MachineTeamOption(ctfy.core.models.CtfyModel):
50class MachineTeamOption(CtfyModel):
51    """A team an organiser may hand a machine to: id and name only."""
52
53    id: str
54    name: str
55    #: The team holds a live machine (assigning another needs ``replace``).
56    has_machine: bool = False
57    #: Human, not disqualified: what the auto-assign serves.
58    eligible: bool = True

A team an organiser may hand a machine to: id and name only.

id: str = PydanticUndefined
name: str = PydanticUndefined
has_machine: bool = False
eligible: bool = True
class MeResponse(ctfy.server.models.UserInfo):
 78class MeResponse(UserInfo):
 79    """``GET /me`` payload — the logged-in user's full profile.
 80
 81    Extends :class:`UserInfo` with auth-state context so the frontend
 82    can render Settings (linked providers, admin badge) with a single
 83    request. ``providers`` lists provider names currently bound
 84    (e.g. ``["github", "google"]``); ``token_kind`` distinguishes
 85    ``"user"`` (browser session) from ``"fine_grained"`` (CLI / agent
 86    token).
 87
 88    ``is_admin`` is the OR of admin + super_admin so existing clients
 89    keep working without inspecting ``role`` directly.
 90
 91    ``competition_teams`` carries every per-comp team the user is
 92    currently on. The legacy ``current_team_id`` /
 93    ``current_team_name`` fields were dropped — there is no global
 94    "current team" any more.
 95    """
 96
 97    email: str = ""
 98    is_admin: bool = False
 99    role: UserRole = "user"
100    providers: list[str] = Field(default_factory=list)
101    #: Each bound identity in detail (#2772): the per-provider badges.
102    sso: list[SsoBindingInfo] = Field(default_factory=list)
103    #: Every way this account can sign in, in the statistics' fixed order
104    #: (:func:`ctfy.core.user_stats.login_methods_of`) -- the 登录方式 column.
105    login_methods: list[LoginMethod] = Field(default_factory=list)
106    #: The proved school, or ``None`` -- the 学校 column.
107    school: UserSchoolInfo | None = None
108    #: When this account was taken off public standings; ``None`` = it
109    #: counts. On *this* projection because it is the account's own view
110    #: of itself: being invisible to the field without being told is the
111    #: kind of surprise that gets reported as a scoring bug. It is not on
112    #: the public ``UserInfo`` — a player has no business learning which
113    #: accounts belong to the deployment.
114    standings_excluded_at: datetime | None = None
115    #: What an administrator has restricted on this account right now
116    #: (#2774), with the reason -- the account's own view of itself, so a
117    #: read-only person can read why without first being refused.
118    restrictions: list[AccountRestrictionInfo] = Field(default_factory=list)
119    token_kind: TokenKind = "user"
120    #: Which of this account's addresses the badge derives from; ``""``
121    #: is no badge. On *this* projection and never on
122    #: :class:`UserInfo`, for `standings_excluded_at`'s reason inverted:
123    #: the badge itself is public and the address behind it is not, so
124    #: the one reader entitled to it is the account choosing.
125    #:
126    #: ⚠️ It can name an address that earns **no** badge —  unverified,
127    #: or at a domain the operator has not named — and the settings card
128    #: needs exactly that pair to say *"chosen, and nothing is being
129    #: shown"*. Reading ``affiliation`` alone cannot tell that from
130    #: *"nothing chosen"*, which are opposite repairs.
131    affiliation_email: str = ""
132    profile_visibility: dict[str, bool] = Field(default_factory=dict)
133    competition_teams: list[CompetitionMembershipInfo] = Field(default_factory=list)
134    # Competitions this user per-comp-administers. Empty for global
135    # admins/super_admins (already a superset — the frontend treats
136    # ``is_admin`` as "admin of every competition").
137    competition_admin_ids: list[str] = Field(default_factory=list)
138    # Competitions whose registration roster this user may read + rule on.
139    # A superset of ``competition_admin_ids`` (an admin can review), plus
140    # any narrow ``reviewer`` grant. Drives whether the frontend shows the
141    # registrations link; the server re-checks on every request.
142    competition_reviewer_ids: list[str] = Field(default_factory=list)
143    # Competitions whose uploaded challenges this user may read and rule
144    # on (plan P2.3). A superset of ``competition_admin_ids``, plus any
145    # narrow authoring-reviewer grant — the same shape as the list above,
146    # and deliberately **not** a subset of it: this grant reaches no
147    # entrant's registration details. Drives whether the frontend shows
148    # the upload queue; the server re-checks on every request.
149    competition_authoring_reviewer_ids: list[str] = Field(default_factory=list)
150    # True when this request asked to be served as an ordinary player
151    # (``X-Ctfy-View-As: player``). Transient — it describes the
152    # *request*, never the stored account, so ``role`` above already
153    # reads ``user`` while the DB row still says admin. The client
154    # needs it to render the "you are previewing" banner and the way
155    # back out; without it a narrowed session is indistinguishable
156    # from an ordinary one and nobody can leave.
157    viewing_as_player: bool = False
158    # What this account said it came here to do, asked once at sign-up.
159    # ⚠️ ``""`` is "we never asked", never a preference — the welcome
160    # wizard shows *because* of it, and an older server that omits the
161    # field lands on the same default, which is the honest reading.
162    onboarding_intent: OnboardingIntent = ""
163    # Whether this account's address has ever been proved — by an OAuth
164    # provider, an emailed sign-in code, or the verification exchange.
165    #
166    # ⚠️ Defaults **False**, the honest reading: the platform holds no
167    # proof until something writes one, so the withheld value asserts
168    # nothing new. The client must not turn that into a nag on its own —
169    # it reads ``=== false`` **and** the flag below, so an older server
170    # (which sends neither) shows nothing.
171    email_verified: bool = False
172    # Whether the *primary* address itself was ever proved (#2763) -- "can the
173    # platform reach you at the address it mails". Distinct from
174    # ``email_verified``, the gate, which a proved second address satisfies
175    # too: a player who proved their school address beside an unproved
176    # personal primary may write, and the settings card still offers to
177    # prove the primary. Defaults **False**; the client falls back to
178    # ``email_verified`` when an older server omits it.
179    primary_email_verified: bool = False
180    # Whether this deployment refuses writes from an unproved address.
181    # Composed server-side from the platform setting *and* whether mail
182    # can be sent at all: enforcement without a mail provider is an
183    # outage, not a policy, so the server resolves it rather than letting
184    # the browser ``&&`` two facts and reach a different answer than the
185    # gate did. Defaults **False** — a client that cannot tell must never
186    # invent a requirement.
187    email_verification_required: bool = False
188    # Which factor this account re-proves before ``DELETE /me`` runs —
189    # see :mod:`ctfy.server.account_deletion`. Composed server-side for
190    # the reason the field above is: the gate's own predicate, so the
191    # Danger Zone cannot render a control the route will refuse, or skip
192    # one it demands. Defaults ``none``, the behaviour that shipped
193    # before the gate existed.
194    deletion_proof: AccountDeletionProof = "none"
195    # When this account's challenge uploads reopen, while a pause after
196    # repeated rejections runs (plan P2.7); ``None`` when nothing is
197    # paused. Composed server-side from the upload route's own function
198    # (``ctfy.server.authoring_cooldown.paused_until``), so the authoring
199    # page cannot draw a form the route refuses. ⚠️ Defaults ``None`` —
200    # an older server that omits it must never read as a pause.
201    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
sso: list[SsoBindingInfo] = PydanticUndefined
login_methods: list[typing.Literal['password', 'github', 'email_code', 'carsi', 'google', 'oidc']] = PydanticUndefined
school: UserSchoolInfo | None = None
standings_excluded_at: datetime.datetime | None = None
restrictions: list[AccountRestrictionInfo] = PydanticUndefined
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
primary_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 MessageSecrets(ctfy.core.models.CtfyModel):
259class MessageSecrets(CtfyModel):
260    """The withheld values of one message, opened (audited)."""
261
262    message_id: str
263    values: dict[str, str] = Field(default_factory=dict)

The withheld values of one message, opened (audited).

message_id: str = PydanticUndefined
values: dict[str, str] = PydanticUndefined
class MessageTeamOption(ctfy.core.models.CtfyModel):
128class MessageTeamOption(CtfyModel):
129    """A team an organiser may address, named as the organisers may name it
130    in a message (an anonymous team by its public label)."""
131
132    id: str
133    label: str
134    anonymous: bool = False
135    member_count: int = 0

A team an organiser may address, named as the organisers may name it in a message (an anonymous team by its public label).

id: str = PydanticUndefined
label: str = PydanticUndefined
anonymous: bool = False
member_count: int = 0
class MessageThreadDetail(ctfy.core.models.CtfyModel):
156class MessageThreadDetail(CtfyModel):
157    """A thread with its messages, for the conversation page."""
158
159    thread: MessageThreadRow
160    messages: list[SiteMessageView] = Field(default_factory=list)
161    #: Markers to draw between the messages by ``at`` (team threads).
162    events: list[ThreadEvent] = Field(default_factory=list)
163    #: The addressed team's current members (team threads).
164    members: list[ThreadMember] = Field(default_factory=list)
165    can_reply: bool = False
166    reply_refusal: ReplyRefusal = ""
167    #: Seconds until the next reply is allowed, ``0`` when it is.
168    retry_after_s: int = 0
169    max_chars: int = MAX_REPLY_CHARS
170    #: The event runs silent: the composer reminds organisers not to give
171    #: results away. Never a refusal.
172    silent: bool = False

A thread with its messages, for the conversation page.

thread: MessageThreadRow = PydanticUndefined
messages: list[SiteMessageView] = PydanticUndefined
events: list[ThreadEvent] = PydanticUndefined
members: list[ThreadMember] = PydanticUndefined
can_reply: bool = False
reply_refusal: Literal['', 'closed_to_replies', 'rate_minute', 'rate_day'] = ''
retry_after_s: int = 0
max_chars: int = 2000
silent: bool = False
class MessageThreadRow(ctfy.core.models.CtfyModel):
68class MessageThreadRow(CtfyModel):
69    """One thread in a list, as the caller reads it."""
70
71    id: str
72    competition_id: str = ""
73    competition_title: str = ""
74    competition_title_i18n: LocalizedText = Field(default_factory=dict)
75    audience: ThreadAudience
76    team_id: str = ""
77    #: The team as this reader may name it (an anonymous team's public label
78    #: for the organisers, its own name for its members).
79    team_label: str = ""
80    user_id: str = ""
81    user_label: str = ""
82    subject: str
83    origin: ThreadOrigin
84    status: ThreadStatus
85    allow_reply: bool
86    created_at: datetime | None = None
87    last_message_at: datetime | None = None
88    last_side: MessageSide
89    #: ``""`` when the organisers wrote it and the reader is a player.
90    last_author_label: str = ""
91    last_preview: str = ""
92    awaiting: AwaitingSide = ""
93    unread: bool = False
94    #: Which side of the thread the caller reads it from.
95    viewer_side: MessageSide
96    broadcast_id: str = ""

One thread in a list, as the caller reads it.

id: str = PydanticUndefined
competition_id: str = ''
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
audience: Literal['user', 'team'] = PydanticUndefined
team_id: str = ''
team_label: str = ''
user_id: str = ''
user_label: str = ''
subject: str = PydanticUndefined
origin: Literal['organiser', 'player'] = PydanticUndefined
status: Literal['open', 'resolved'] = PydanticUndefined
allow_reply: bool = PydanticUndefined
created_at: datetime.datetime | None = None
last_message_at: datetime.datetime | None = None
last_side: Literal['organiser', 'player'] = PydanticUndefined
last_author_label: str = ''
last_preview: str = ''
awaiting: Literal['', 'organiser', 'player'] = ''
unread: bool = False
viewer_side: Literal['organiser', 'player'] = PydanticUndefined
broadcast_id: str = ''
class MetaAgents(ctfy.core.models.CtfyModel):
 90class MetaAgents(CtfyModel):
 91    """How much of this deployment's population is machine, not human.
 92
 93    Admin-only, like the cluster counters it sits beside — an entrant
 94    count is competitive telemetry.
 95
 96    ⚠️ **Three numbers, because they are three different questions and
 97    only the first is "how many AI agents did users register".** A
 98    harness is the registered image; an *entrant* is one synthetic user
 99    per (owner, harness, model), so three agents across four models is
100    twelve entrants; a *run* is one dispatch. Collapsing them into a
101    single "agents" figure picks one meaning and makes the other two
102    unreadable — and the one a reader assumes is the one this field set
103    exists to name.
104    """
105
106    #: Harnesses with a real owner — the answer to "how many agents did
107    #: our users register". ⚠️ Deliberately excludes the platform's own
108    #: rows, which carry ``owner_user_id == ""``; that empty string is a
109    #: value on the column (it is what ``is_dispatchable`` reads as
110    #: "built here, runnable as it stands"), not an absent owner. There is
111    #: no "awaiting review" count: the review was removed (#2614).
112    registered: int = 0
113    #: The platform's own harnesses — the complement of ``registered``,
114    #: carried so a reader can tell "nobody has registered one yet" from
115    #: "the registry is empty".
116    platform: int = 0
117    #: Synthetic agent users (``UserState.entrant_kind == "agent"``).
118    entrants: int = 0
119    #: Active registered models an agent can be pointed at.
120    models: int = 0
121    #: Evaluation runs in flight — the agent twin of
122    #: ``running_instances``.
123    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
platform: int = 0
entrants: int = 0
models: int = 0
runs_running: int = 0
class MetaAlert(ctfy.core.models.CtfyModel):
183class MetaAlert(CtfyModel):
184    """One row of the status bar's alerts popover (#2150): what is wrong, how
185    much of it, about which node, and since when.
186
187    ``kind`` is a closed set the browser owns the words, icon and link for;
188    the server sends no prose, so a translated page never shows English.
189    """
190
191    kind: AlertKind
192    count: int = 0
193    #: The node, for the node-scoped kinds (its short name); ``""`` otherwise.
194    subject: str = ""
195    #: When it happened, or was last seen. ``None`` is *not known*.
196    at: AwareDatetime | None = None

One row of the status bar's alerts popover (#2150): what is wrong, how much of it, about which node, and since when.

kind is a closed set the browser owns the words, icon and link for; the server sends no prose, so a translated page never shows English.

kind: Literal['tasks_failed', 'outbox_failed', 'images_unready', 'node_offline', 'smoke_node_offline'] = PydanticUndefined
count: int = 0
subject: str = ''
at: pydantic.types.AwareDatetime | None = None
class MetaAlerts(ctfy.core.models.CtfyModel):
199class MetaAlerts(CtfyModel):
200    """What is wrong right now. Admin-only, with the rest of ``ops``.
201
202    ⚠️ ``total`` counts every alert; ``items`` is the newest few. The chip
203    reads ``total`` and the popover ``items``, and a bound on the second must
204    not make the first smaller.
205    """
206
207    total: int = 0
208    items: list[MetaAlert] = Field(default_factory=list)

What is wrong right now. Admin-only, with the rest of ops.

⚠️ total counts every alert; items is the newest few. The chip reads total and the popover items, and a bound on the second must not make the first smaller.

total: int = 0
items: list[MetaAlert] = PydanticUndefined
class MetaAuthoredArchive(ctfy.core.models.CtfyModel):
392class MetaAuthoredArchive(CtfyModel):
393    """What an uploaded challenge archive may be, in bytes.
394
395    ⚠️ **Published so the upload form can say it, and it could not.**
396    An archive over the limit came back as *too large to upload* with no
397    number anywhere on the page — the server's own message carried one,
398    and the page shows the error code's copy instead — so an author who
399    was refused learned nothing they could act on. The form reads these
400    to state the limits beside the file picker and to refuse an
401    oversized file before sending it.
402
403    ⚠️ **Zero is "this server did not say"**, which is what an older
404    platform sends: a client then states no limit and refuses nothing
405    itself, leaving the verdict to the server — never a guessed number
406    that could refuse an upload the server would take.
407    """
408
409    #: ``max_authored_challenge_bytes`` — the archive as sent, compressed.
410    archive_bytes: int = 0
411    #: ``MAX_TOTAL_BYTES`` — every file in it, unpacked.
412    unpacked_bytes: int = 0
413    #: ``MAX_FILE_BYTES`` — any one file, unpacked.
414    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):
66class MetaChallenges(CtfyModel):
67    """Source-control identity of the challenges repository, plus the
68    public count of available challenges.
69
70    ``commit_sha`` is None when the challenges directory isn't a git
71    working tree (e.g. ``CTFY_CHALLENGES_REPO=""`` or a bare checkout).
72    ``total`` is visible to everyone — the list itself is browsable.
73
74    ``suites`` / ``environments`` are the other two axes of "how big is
75    the catalog" (see :class:`~ctfy.core.challenge.CatalogCounts`):
76    distinct source buckets, and the subset that ships a runnable
77    Docker topology rather than being a pure QA item. Both are derived
78    from the same public spec list, so they carry no more information
79    than ``GET /challenges`` already does.
80    """
81
82    commit_sha: str | None = None
83    commit_url: str | None = None
84    repo_url: str = ""
85    total: int = 0
86    suites: int = 0
87    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):
363class MetaContribute(CtfyModel):
364    """Where the Contribute entry leads, as *this caller* may be shown it.
365
366    Composed by ``ctfy.server.contribute.contribute_for``: the operator's
367    ``contribute_competition_id``, kept only while that competition takes
368    challenges and only for a caller who may see it.
369
370    ⚠️ **``MetaResponse.contribute`` being ``None`` means "no entry",
371    and an older server that sends no field means the same.** A client
372    hides the sidebar item, the home card and the page's destination on
373    it — an entry that leads to a 404, or to "authoring has closed", is
374    worse than no entry.
375
376    ⚠️ **Per caller, never shared**, because visibility is: publishing
377    the id of a ``private_hidden`` competition to everybody is the
378    existence probe ``resolve_visible_competition`` answers 404 to
379    prevent.
380    """
381
382    #: The competition the entry leads to.
383    competition_id: str
384    #: Its title, so the entry can say where it leads without a second
385    #: request.
386    title: str = ""
387    #: The title per locale when this row is a competition (#2230); a
388    #: browser renders this, ``title`` is its base-locale reading.
389    title_i18n: LocalizedText = Field(default_factory=dict)

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 = ''
title_i18n: dict[str, str] = PydanticUndefined
class MetaEvents(ctfy.core.models.CtfyModel):
288class MetaEvents(CtfyModel):
289    """How many competitions are open, as the *caller* may see them.
290
291    ⚠️ **Public, and that is a decision about ``access`` as well as
292    ``status``.** A published competition can still be
293    ``private_hidden``, which a non-invited user must not be able to
294    learn exists — the same probe ``competition_visible_to`` refuses by
295    answering 404 rather than 403. So this counts published rows that
296    are not hidden, which is exactly what the browsable catalog already
297    lists: no new disclosure, and a player finally gets the one number
298    that tells them there is something to play right now.
299
300    ⚠️ It under-reports for an invited user (their hidden event is not
301    in it). That is the safe direction and the catalog page shows them
302    the event anyway; the alternative is a per-caller invite join on a
303    chip.
304    """
305
306    live: int = 0
307    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 MetaFreeze(ctfy.core.models.CtfyModel):
211class MetaFreeze(CtfyModel):
212    """Image-freeze progress over the freeze tasks in flight (#2150).
213
214    Present only while one is running: ``MetaOps.freeze`` is ``None`` the rest
215    of the time, never a ``0 %`` — see ``ctfy.core.status_alerts``.
216    """
217
218    percent: int = 0
219    challenges_left: int = 0
220    challenges_total: int = 0

Image-freeze progress over the freeze tasks in flight (#2150).

Present only while one is running: MetaOps.freeze is None the rest of the time, never a 0 % — see ctfy.core.status_alerts.

percent: int = 0
challenges_left: int = 0
challenges_total: int = 0
class MetaInstanceTtl(ctfy.core.models.CtfyModel):
329class MetaInstanceTtl(CtfyModel):
330    """How long an instance may run, and what a client may offer for it.
331
332    ⚠️ **``options_s`` is composed by the server, and that is the whole
333    reason this group exists.** ``resolve_instance_ttl`` clamps a
334    caller's duration to ``max_instance_ttl_s`` *silently*, so a picker
335    that narrowed its own list would eventually offer a value the server
336    lowers without saying so — a control that lies about what pressing
337    it does. Nothing selectable from this list is clamped.
338
339    ⚠️ **Public, deliberately.** These are lifetimes, not capacity: a
340    player learns the default the instant their first countdown starts,
341    and a guest deciding whether to sign up is entitled to know a box
342    lives three hours rather than three minutes.
343
344    ⚠️ **An empty ``options_s`` is "this server did not say"**, which is
345    what an older platform sends, so a client reads it as *offer no
346    picker* rather than *offer nothing*. The single-press renewal still
347    works there: omitting ``ttl`` is what delegates to the platform.
348    """
349
350    #: ``default_instance_ttl_s`` — what a caller who names no duration
351    #: gets, and the option a picker should land the keyboard on.
352    default_s: int = 0
353    #: ``max_instance_ttl_s``. On a **renewal** this bounds the
354    #: resulting deadline rather than the increment, so it is also the
355    #: most time a box can ever have ahead of it — see
356    #: :func:`ctfy.core.instance_ttl.plan_renewal`.
357    max_s: int = 0
358    #: Ascending, every entry in ``[1, max_s]``, always carrying both
359    #: ``default_s`` and ``max_s``.
360    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):
268class MetaLlm(CtfyModel):
269    """What the LLM gateway relayed today. Admin-only.
270
271    ⚠️ Counted from ``llm_calls`` — calls the platform itself relayed —
272    never from what an agent reported about itself. A run whose harness
273    left its rollup unset is invisible to the latter and fully counted
274    here.
275
276    ⚠️ Tokens, not dollars. Pricing a window means joining every row to
277    a model rate, and an unpriced model costs ``None`` rather than zero,
278    so a single figure here would silently under-report exactly the
279    models nobody has priced yet. The admin LLM pages own that sum.
280    """
281
282    #: Since 00:00 UTC today.
283    calls_today: int = 0
284    input_tokens_today: int = 0
285    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):
310class MetaMine(CtfyModel):
311    """What *this caller* has, as opposed to what the platform has.
312
313    Present for any signed-in user, admin or not — a status bar that
314    only ever spoke to operators left a player no way to see their own
315    agents and runs in flight.
316
317    ⚠️ **Only what nothing else already answers.** Solves, attempts and
318    the caller's running instances reach the bar through ``/me`` and
319    ``InstanceCountContext``; repeating them here would give one number
320    two sources, which is the shape that drifts.
321    """
322
323    #: Harnesses this caller owns.
324    agents: int = 0
325    #: This caller's own evaluation runs in flight.
326    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 no way to see their own agents and runs in flight.

⚠️ 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
runs_running: int = 0
class MetaNode(ctfy.core.models.CtfyModel):
135class MetaNode(CtfyModel):
136    """One worker node, as the status bar shows it (#2018). Admin-only.
137
138    ⚠️ **No address, no token, no raw labels.** ``GET /api/v1/nodes``
139    once answered the internet with the fleet; this rides inside
140    :class:`MetaOps`, so it is ``null`` for exactly the callers ``ops``
141    is, and it carries only what a chip and its hover table draw.
142    """
143
144    id: str
145    #: ``display_name``, or the id when the operator gave none -- either one
146    #: reduced to a short host name when it is an address (#2150).
147    name: str
148    health: NodeHealth
149    cordoned: bool = False
150    #: The node's last report, 0–100. Old whenever ``health`` is not
151    #: ``up`` — the chip says so rather than hiding the numbers.
152    cpu_percent: float = 0.0
153    memory_percent: float = 0.0
154    disk_percent: float = 0.0
155    #: Physical RAM, MiB. ``0`` is *not reported* (an older node, a failed
156    #: probe), never an empty machine — and then ``memory_used_mb`` is 0
157    #: too, because it is derived from it.
158    memory_total_mb: float = 0.0
159    memory_used_mb: float = 0.0
160    #: What the platform has *promised* to live instances here, from their
161    #: declared sizes (:func:`ctfy.server.scheduling.committed_memory_by_node`)
162    #: — the admission side, shown beside the measured ``memory_used_mb``
163    #: so the gap between declared and real is readable (#2017).
164    committed_memory_mb: float = 0.0
165    running: int = 0
166    capacity: int = 0
167    pools: list[MetaNodePool] = Field(default_factory=list)
168    version: str = ""
169    git_commit: str = ""
170    corpus_commit: str = ""
171    #: Unix seconds; ``0`` is never heard from.
172    last_heartbeat_ts: float = 0.0
173    #: :func:`ctfy.core.node_pressure.node_pressure` — the chip's colour.
174    pressure: NodePressure = "unknown"
175
176    @computed_field
177    @property
178    def tightest_percent(self) -> float:
179        """The most loaded axis — the number the chip prints."""
180        return tightest_percent(self.cpu_percent, self.memory_percent, self.disk_percent)

One worker node, as the status bar shows it (#2018). Admin-only.

⚠️ No address, no token, no raw labels. GET /api/v1/nodes once answered the internet with the fleet; this rides inside MetaOps, so it is null for exactly the callers ops is, and it carries only what a chip and its hover table draw.

id: str = PydanticUndefined
name: str = PydanticUndefined
health: Literal['up', 'stale', 'unhealthy'] = PydanticUndefined
cordoned: bool = False
cpu_percent: float = 0.0
memory_percent: float = 0.0
disk_percent: float = 0.0
memory_total_mb: float = 0.0
memory_used_mb: float = 0.0
committed_memory_mb: float = 0.0
running: int = 0
capacity: int = 0
pools: list[typing.Literal['heavy_estates', 'quarantine_builds', 'untrusted_builds', 'freeze_builds']] = PydanticUndefined
version: str = ''
git_commit: str = ''
corpus_commit: str = ''
last_heartbeat_ts: float = 0.0
pressure: Literal['ok', 'high', 'critical', 'unknown'] = 'unknown'
tightest_percent: float
176    @computed_field
177    @property
178    def tightest_percent(self) -> float:
179        """The most loaded axis — the number the chip prints."""
180        return tightest_percent(self.cpu_percent, self.memory_percent, self.disk_percent)

The most loaded axis — the number the chip prints.

MetaNodePool = typing.Literal['heavy_estates', 'quarantine_builds', 'untrusted_builds', 'freeze_builds']
class MetaOps(ctfy.core.models.CtfyModel):
223class MetaOps(CtfyModel):
224    """Operator-health counters: is anything wrong right now?
225
226    Admin-only. Every field here is a number an operator would want to
227    be non-zero-alerted on, which is why they sit together rather than
228    among the population counts.
229    """
230
231    #: Competitions a player can be inside *right now* — published and
232    #: within a session window. ⚠️ Both halves, not just the clock: a
233    #: draft competition inside its own window is "running" by the dates
234    #: and invisible to every player, so a phase-only count reports
235    #: events nobody can enter as live ones.
236    #:
237    #: ⚠️ **Not the same number as ``MetaEvents.live``**, and the pair is
238    #: kept rather than reconciled: this one includes ``private_hidden``
239    #: events, which the public count must not. An operator asking "is
240    #: anything live" wants the true answer including the invite-only
241    #: ones; showing them the public number instead would under-report
242    #: in the one direction that hides an event from the person
243    #: responsible for it. The panel shows both rows rather than a
244    #: difference, so neither figure has to be derived by a reader.
245    competitions_live: int = 0
246    competitions_upcoming: int = 0
247    #: Background tasks. ⚠️ ``failed`` is lifetime, not a window — the
248    #: chip is a pointer at the task list, and a failure that scrolled
249    #: out of a 24h window is still a failure nobody looked at.
250    tasks_running: int = 0
251    tasks_failed: int = 0
252    #: Notification outbox. A backed-up queue is otherwise invisible
253    #: until somebody reports never getting their mail.
254    outbox_pending: int = 0
255    outbox_failed: int = 0
256    #: Every registered node, in id order (#2018). The bar sorts for its
257    #: own collapse; the wire order is stable so two frames compare.
258    nodes: list[MetaNode] = Field(default_factory=list)
259    #: The alerts popover's rows, composed on the server from the failed
260    #: tasks, the failed outbox, challenges not yet ready before a session
261    #: and nodes that have gone quiet. ``tasks_failed`` / ``outbox_failed``
262    #: above stay what they were; the chip's number is ``alerts.total``.
263    alerts: MetaAlerts = Field(default_factory=MetaAlerts)
264    #: The image freeze's progress while one runs, else ``None``.
265    freeze: MetaFreeze | None = None

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
nodes: list[MetaNode] = PydanticUndefined
alerts: MetaAlerts = PydanticUndefined
freeze: MetaFreeze | None = None
class MetaPlatform(ctfy.core.models.CtfyModel):
52class MetaPlatform(CtfyModel):
53    """Source-control identity of the running ctfy build.
54
55    ``commit_sha`` is None when the build couldn't determine its own
56    revision (no ``CTFY_GIT_COMMIT``, no usable git binary).
57    ``commit_url`` is built from ``repo_url`` + sha so the frontend
58    doesn't have to know the URL convention.
59    """
60
61    commit_sha: str | None = None
62    commit_url: str | None = None
63    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):
417class MetaResponse(CtfyModel):
418    version: str = ""
419    platform: MetaPlatform = Field(default_factory=MetaPlatform)
420    challenges: MetaChallenges = Field(default_factory=MetaChallenges)
421    started_at_ts: float = 0.0
422    server_time_ts: float = 0.0
423    # Cluster-wide counters — admin-only. ``None`` for non-admin
424    # callers so the frontend can distinguish "not authorised" from
425    # "happens to be zero" and hide the segment entirely instead of
426    # rendering a misleading "0 nodes".
427    teams_total: int | None = None
428    users_total: int | None = None
429    nodes_total: int | None = None
430    nodes_healthy: int | None = None
431    running_instances: int | None = None
432    solves_total: int | None = None
433    # The three admin-only groups above. ``None`` rather than a
434    # default-constructed object for the same reason every scalar
435    # beside them is: the frontend must be able to tell "not authorised"
436    # from "happens to be zero", and a zeroed group renders as a
437    # deployment with no agents, no live events and an idle gateway —
438    # a claim, where ``None`` is silence.
439    agents: MetaAgents | None = None
440    ops: MetaOps | None = None
441    llm: MetaLlm | None = None
442    # Public — every caller, guests included. See :class:`MetaEvents`
443    # for why a count of open competitions discloses nothing the
444    # catalog does not.
445    events: MetaEvents = Field(default_factory=MetaEvents)
446    # Public, like ``events`` above and for the same reason: a player
447    # meets these numbers the moment they launch anything, and the
448    # picker that reads them cannot be built without the ceiling.
449    instance_ttl: MetaInstanceTtl = Field(default_factory=MetaInstanceTtl)
450    # Per-caller. ``None`` for a signed-out visitor, who has no agents
451    # rather than zero of them — the same distinction the admin groups
452    # above draw, one identity down.
453    mine: MetaMine | None = None
454    # Per-caller, guests included: where the Contribute entry leads, or
455    # ``None`` for no entry. See :class:`MetaContribute`.
456    contribute: MetaContribute | None = None
457    # Public: a limit is a fact about the deployment, and an author
458    # deciding what to pack is entitled to it before signing in.
459    authored_archive: MetaAuthoredArchive = Field(default_factory=MetaAuthoredArchive)
460    # Public surface bit for the super-admin docker-exec feature. The
461    # frontend uses this to decide whether to render the Shell entry
462    # in the admin instance list — false means the routes aren't
463    # mounted, so any UI link would 404. Never carries the
464    # token/recording details — those stay server-side.
465    admin_shell_enabled: bool = False
466    # Where a defender's own `ssh` should knock, and — by being empty —
467    # whether the channel exists at all. ``POST /instances/{id}/ssh``
468    # refuses with 503 unless the CA key *and* the bastion host are both
469    # configured, so these two report the same pair rather than the raw
470    # config values: advertising a host with no CA would put a command
471    # on the defence card that always 503s, which a player reads as
472    # their box being broken. Public deliberately, unlike the counters
473    # below — it is the address the player types, and knowing where to
474    # knock buys nothing without a certificate the platform mints per
475    # instance. ``ssh_bastion_port`` is 0 (not its 2222 config default)
476    # when the channel is off, so neither field can imply a bastion the
477    # other denies.
478    ssh_bastion_host: str = ""
479    ssh_bastion_port: int = 0
480    # Operator-set deployment label (``CTFY_ENVIRONMENT_LABEL``), shown
481    # as a badge next to the brand so a staging tab is visually distinct
482    # from production. Empty = no badge. Public on purpose: it names the
483    # deployment, not anything about its internals.
484    environment_label: str = ""
485    # Canonical tier derived from the label (``ctfy.core.environment``).
486    # The client keys the frame / favicon tone and the prod-only confirm
487    # friction off this instead of substring-matching the free-text label
488    # itself, so classification lives in one place. ``prod`` is the
489    # fail-safe default (the unmarked deployment is production).
490    environment: Environment = "prod"
491    # The public status page (#2921, ``status_page_url``). Public: it is the
492    # link the top-bar entry opens. Empty = no entry.
493    status_page_url: 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.

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'
status_page_url: str = ''
class MilestoneProgress(ctfy.core.models.CtfyModel):
1135class MilestoneProgress(CtfyModel):
1136    """Per-challenge milestone progress for the calling user.
1137
1138    Returned by ``GET /me/milestone-progress``. One row per challenge
1139    with at least one captured question, regardless of whether the
1140    challenge is fully solved. The Challenges list page renders the
1141    number of solved milestones, never a denominator: how many
1142    objectives the challenge declares is one of the things an exercise
1143    withholds (#2435).
1144
1145    Like ``MySolveSummary`` this is user-scoped: ``solved_question_ids``
1146    aggregates every question id the user captured for the challenge
1147    across every team they have ever been on. The optional
1148    ``competition_id`` query param narrows the aggregation to solves
1149    stamped against the user's team in that comp.
1150    """
1151
1152    challenge_id: str
1153    solved_question_ids: list[str] = Field(default_factory=list)

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 the number of solved milestones, never a denominator: how many objectives the challenge declares is one of the things an exercise withholds (#2435).

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
class ModelStanding(ctfy.core.models.CtfyModel):
104class ModelStanding(CtfyModel):
105    """One model's overall standing plus its per-dimension breakdowns."""
106
107    rank: int = 0
108    #: Which metric put this row below the one above it (#2341, L8):
109    #: ``None`` on the first row; ``solved`` when the counts differ, and a
110    #: secondary metric when they do not. Ranks are never shared.
111    decided_by: RankMetric | None = None
112    model_id: str = ""
113    display_name: str = ""
114    #: SDK wire protocol the platform dials this model with — an
115    #: *addressing* detail with no bearing on the score.
116    #:
117    #: ⚠️ **Not the vendor**, though both its values are vendor names: a
118    #: DeepSeek model served over the anthropic dialect carries
119    #: ``flavor="anthropic"``. Anything rendering this unlabelled beside a
120    #: model name is publishing a false attribution.
121    flavor: FlavorOrUnset = ""
122    #: owning vendor / org slug (blank when unattributed).
123    vendor: str = ""
124    #: the vendor's curated display name, falling back to the slug.
125    #:
126    #: ⚠️ Blank **only** when ``vendor`` is blank, so a reader can tell
127    #: "unattributed" from "attributed to a vendor with no curated row" —
128    #: the second is a label, the first is silence.
129    vendor_display_name: str = ""
130    #: provider model id actually requested (e.g. ``deepseek-chat``).
131    provider_model: str = ""
132    #: the harnesses whose runs this row pools, busiest first.
133    harnesses: list[ModelHarnessRef] = Field(default_factory=list)
134    runs: int = 0
135    challenges_attempted: int = 0
136    #: Challenges the platform never presented to this model at all — a
137    #: range that would not start, a node that answered 5xx, a fleet with
138    #: no capacity. ⚠️ Published beside ``challenges_attempted`` because
139    #: ``solve_rate`` alone cannot express it: the rate has always
140    #: divided by challenges that produced a run, so it is honest and
141    #: says nothing about how much of the set it covers. Measured: a 50%
142    #: over 26 challenges ranked beside a 66% over 102.
143    challenges_errored: int = 0
144    challenges_solved: int = 0
145    solve_rate: float = 0.0
146    questions_solved: int = 0
147    input_tokens: int = 0
148    output_tokens: int = 0
149    #: input + output across all the model's succeeded runs.
150    total_tokens: int = 0
151    #: cost-efficiency: total tokens per solved challenge (0 if none solved).
152    tokens_per_solve: float = 0.0
153    #: estimated USD cost (tokens × the model's price); 0 when unpriced.
154    cost_usd: float = 0.0
155    #: USD per solved challenge (0 when unpriced or nothing solved).
156    cost_per_solve: float = 0.0
157    avg_turns: float = 0.0
158    #: deepest pass@k sampling — max succeeded runs against any one challenge.
159    max_samples: int = 0
160    #: corpus version these runs were graded against (the model's modal sha).
161    corpus_sha: str = ""
162    #: When this model's most recent counted run was created (#2097); ``None``
163    #: when no run carries a timestamp, never an epoch.
164    last_run_at: AwareDatetime | None = None
165    #: dimension name (``difficulty`` / ``bucket`` / ``tag``) → its cells.
166    breakdowns: dict[str, list[LeaderboardCell]] = Field(default_factory=dict)
167    #: Fewer counted runs than :data:`~ctfy.core.suites.LOW_SAMPLE_RUNS`:
168    #: still ranked, flagged so a reader weighs it (#2341).
169    low_sample: bool = False
170    #: ⚠️ **#2614: the rank and the headline are the mean over campaigns**
171    #: (``core.eval_board_mean``), one basis for every table on the page.
172    #: ``challenges_solved`` / ``solve_rate`` above pool every sample
173    #: (best-of) and stay only as coverage; nothing ranks on or shows them.
174    #: Standalone runs (no campaign) are in no mean. ``campaigns == 0`` is
175    #: unmeasured, never a zero.
176    campaigns: int = 0
177    mean_solved: float = 0.0
178    mean_attempted: float = 0.0
179    mean_solve_rate: float = 0.0
180    #: Each campaign's solve rate, oldest first: the spread, kept visible.
181    campaign_solve_rates: list[float] = Field(default_factory=list)
182    #: Manifest challenges this model measured (#2733); ``None`` without a
183    #: manifest, as on :class:`PairStanding`.
184    manifest_covered: int | None = None

One model's overall standing plus its per-dimension breakdowns.

rank: int = 0
decided_by: Optional[Literal['solved', 'solve_rate', 'tokens_per_solve', 'avg_turns', 'last_run_at', 'id']] = None
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 = ''
last_run_at: pydantic.types.AwareDatetime | None = None
breakdowns: dict[str, list[LeaderboardCell]] = PydanticUndefined
low_sample: bool = False
campaigns: int = 0
mean_solved: float = 0.0
mean_attempted: float = 0.0
mean_solve_rate: float = 0.0
campaign_solve_rates: list[float] = PydanticUndefined
manifest_covered: int | None = None
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):
2262class MyEvalRunPage(CtfyModel):
2263    """A page of runs, plus the same two envelope facts as the calls."""
2264
2265    items: list[MyEvalRunRow] = Field(default_factory=list)
2266    total: int = 0
2267    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):
1773class MyEvalRunRow(CtfyModel):
1774    """One of your (or a team-mate's) evaluation runs, for the shelf.
1775
1776    ⚠️ **``harness_id`` and ``model_id``, never the synthetic entrant
1777    id.** ``EvalRunState.team_id`` is a one-person team minted per
1778    ``(entrant, competition)``, so a page that showed it would print a
1779    string nobody recognises and imply a team that has no members.
1780
1781    ⚠️ ``mine`` is composed **server-side**. Both the run's author and
1782    the caller reach the browser on other surfaces, so a component
1783    *could* compare them — and then two clients would disagree about
1784    whether the conversation button is enabled. Same argument
1785    ``expects_report`` makes.
1786    """
1787
1788    #: How many times this entrant has rebuilt this challenge's
1789    #: environment, from the permanent instance history.
1790    #:
1791    #: ⚠️ **``None`` means this surface did not say**, never "never
1792    #: reset". The shelf withholds it — the count is a point query per
1793    #: ``(team, challenge)`` and a fifty-row page would pay it fifty
1794    #: times for a number nobody asked for — so only the single-run read
1795    #: composes it. A ``0`` default would make the two surfaces disagree
1796    #: about a run that *had* been reset, which is the one comparison
1797    #: this field exists for: a model that reset five times and one that
1798    #: never did are not like for like, and until now that difference was
1799    #: invisible in the data.
1800    resets: int | None = None
1801
1802    id: str = ""
1803    harness_id: str = ""
1804    harness_version_id: str = ""
1805    model_id: str = ""
1806    competition_id: str = ""
1807    challenge_id: str = ""
1808    #: The estate this run was dispatched against. A foreign key, so
1809    #: ``instance_id`` rather than ``id`` — and the field the traffic
1810    #: read is addressed by, which is why the row carries it at all: the
1811    #: run knew which box it drove and the projection did not, so
1812    #: nothing on the shelf could link to it.
1813    instance_id: str = ""
1814    status: str = ""
1815    created_at_ts: float = 0.0
1816    #: The campaign this run is one sample of; ``""`` for a standalone
1817    #: run. Lets the run page (#2401) list the run's siblings through
1818    #: ``GET /eval/runs?campaign_id=``, which is scoped exactly as this
1819    #: row is — so a team-mate reaches the same tree an owner does.
1820    campaign_id: str = ""
1821    #: When the harness started and ended; ``0.0`` while it has not.
1822    started_at_ts: float = 0.0
1823    finished_at_ts: float = 0.0
1824    #: Whose run this is, for a team-wide list.
1825    owner_user_id: str = ""
1826    #: What that person is called, resolved for display — never the id,
1827    #: which is already on the wire beside it. ``""`` means *nothing to
1828    #: show*, never "we did not look": `ctfy.server.entity_labels` is the
1829    #: one producer, and its third state is the whole reason this is not
1830    #: composed ``label or owner``, which is what it was.
1831    #:
1832    #: ⚠️ **What that blank actually means here is "no display name", not
1833    #: the convention's usual "deleted account".** `readable_runs`
1834    #: derives its audience from membership rows and `delete_user`
1835    #: cascades them, so a deleted owner drops out of the audience with
1836    #: their run rather than appearing unnameable — measured, and pinned
1837    #: by `test_a_deleted_account_is_not_listed_at_all`. Reading this
1838    #: field's blank as a tombstone would be a sentence about a state
1839    #: this route cannot reach.
1840    #:
1841    #: ⚠️ **`player_label`, so never an address.** The audience is the
1842    #: caller's team-mates *at one competition* (`list_my_runs`), and
1843    #: the roster they already open publishes a display name and no
1844    #: email — `_author_name`'s split on the same audience.
1845    owner_display_name: str = ""
1846    #: Whether *this* caller is the run's author — who may consent (wall,
1847    #: transcript) and cancel. The list is the team's; the bodies are the
1848    #: author's, and (#2930) a platform admin's.
1849    mine: bool = False
1850    #: #2930: who *this* caller is to the run. ``author`` reads everything;
1851    #: ``operator`` (a platform admin, not the author) reads every body and
1852    #: each body read is audited, but gives no consent; ``team`` reads the
1853    #: metadata only. ⚠️ The client gates bodies on ``reader != "team"``,
1854    #: never on ``mine`` — that is the author, which an admin is not.
1855    reader: Literal["author", "operator", "team"] = "team"
1856    #: #3444: an AI pre-check run of a pending upload's version, read by its
1857    #: staff (the upload's reviewers and organisers, platform admins) —
1858    #: never listed, never announced. ⚠️ No live frame reaches its
1859    #: reviewers (``body_audience`` is a platform admin's), so the page
1860    #: polls it while it runs. ``False`` from an older server.
1861    precheck: bool = False
1862    #: #3445: the upload a pre-check run's version belongs to — the run page's
1863    #: breadcrumb leads back to its AI pre-check tab. ``""`` for every other
1864    #: run, a version that is gone, and an older server.
1865    authored_challenge_id: str = ""
1866    #: #3446: a pre-check run's step — where it is, or the one it stopped in
1867    #: (``PrecheckRunRow.phase``). ``""`` for every other run and a pre-check
1868    #: row written before it was recorded. Only the run's staff read a
1869    #: pre-check run at all (``may_read_metadata``).
1870    precheck_phase: Literal["", PrecheckPhase] = ""
1871    #: #3445: a pre-check run's arm — the run page's stage bar draws no
1872    #: "Starting range" step for ``source``, which reads the upload's files
1873    #: and boots no box. ``""`` for every other run and an older server.
1874    precheck_arm: Literal["", PrecheckArm] = ""
1875    #: Why this run's traffic cannot be read, or ``""`` when it can.
1876    #:
1877    #: ⚠️ **A code, not a bool, and the client tests membership of
1878    #: :data:`RUN_TRAFFIC_BLOCKED` rather than truthiness.** The three
1879    #: closed cases each need their own sentence, and — the half that
1880    #: decides the type — an older server sends no field at all, which
1881    #: arrives as ``""``. A ``traffic_available: bool`` read as
1882    #: ``=== false`` would then be *false* for everyone and the control
1883    #: would vanish platform-wide with nothing reporting it. An
1884    #: unrecognised value is likewise not blocked, so a server that
1885    #: learns a fourth reason degrades to a live button rather than a
1886    #: missing one.
1887    traffic: str = ""
1888    #: Why this run's collected archive cannot be downloaded, or ``""``
1889    #: when it can.
1890    #:
1891    #: ⚠️ **Same type and same reading as ``traffic`` one field up**, and
1892    #: for the same reasons: the closed cases are the set, everything
1893    #: outside it is open, and an older server's absent field arrives as
1894    #: ``""`` and must read as *not blocked*.
1895    #:
1896    #: ⚠️ Fail-open here means a run from before collection existed
1897    #: offers a button that answers 404. That is the accepted trade this
1898    #: repository makes everywhere: a wrong ``True`` costs a click, a
1899    #: wrong ``False`` takes the control away from everybody the day the
1900    #: server learns a new reason.
1901    artifact: str = ""
1902    #: Size of the stored archive, so the page can say what a click will
1903    #: cost before it costs it. ``0`` when there is nothing to download.
1904    artifact_bytes: int = 0
1905    #: Why this run's write-up cannot be read, or ``""`` when it can.
1906    #:
1907    #: ⚠️ **A code and not the prose, because the prose is capped at
1908    #: 100 000 characters** and this row rides a page of fifty. Same
1909    #: split the archive makes one field up: the shelf says whether
1910    #: there is one, the text is addressed.
1911    #:
1912    #: ⚠️ Same type and same reading as ``traffic`` and ``artifact``
1913    #: above — the closed cases are the set, everything outside it is
1914    #: open, and an older server's absent field arrives as ``""`` and
1915    #: must read as *not blocked*.
1916    writeup: str = ""
1917    #: What the run has spent so far, from the run row itself.
1918    input_tokens: int = 0
1919    output_tokens: int = 0
1920    #: How many calls the gateway actually relayed for this run. ``0``
1921    #: beside a ``usage_source`` of ``agent`` is the pair that says the
1922    #: two token counts above are the agent's own word about itself.
1923    relayed_calls: int = 0
1924    #: Who counted those figures — ``gateway`` where this deployment
1925    #: measured them, ``agent`` where the run reported its own.
1926    #:
1927    #: ⚠️ **The default withholds, and ``"agent"`` would be a claim in
1928    #: the accusing direction.** The shelf's warning line keys on
1929    #: ``agent``, so a producer that forgot to forward this field would
1930    #: accuse every row of a working deployment. ``""`` is *this server
1931    #: did not say* — which is also how an older server's absent field
1932    #: arrives — and it is what lets
1933    #: `TestWhatTheRunSpentIsTheCountedNumber`'s unmeasured arm fail at
1934    #: all: a default of ``agent`` agrees with the value that arm
1935    #: asserts, so the mutation deleting both forwarding lines left it
1936    #: green and only the measured arm reddened.
1937    #:
1938    #: ⚠️ **``str``, never a ``Literal``** — `KeySpec`'s reason. The one
1939    #: reader compares *positively* against ``agent``, so a provenance a
1940    #: newer server learns degrades to silence rather than becoming
1941    #: unrepresentable in the client's own fail-quiet test.
1942    usage_source: str = ""
1943    #: #2356: whether this run's agent could reach the public internet
1944    #: ("本测评是否联网"), as recorded at dispatch — never re-read from the
1945    #: competition, so an organiser changing it later does not rewrite
1946    #: what an earlier run had. ``""`` says nothing: a platform image (no
1947    #: sandbox) or a run dispatched before the field.
1948    agent_internet: Literal["", "allow", "deny"] = ""
1949    #: Whether this run's **entrant** is on the event's projector wall.
1950    #:
1951    #: ⚠️ **A property of the entrant, not of the run**, and the field
1952    #: says so rather than pretending otherwise:
1953    #: ``TeamState.wall_published_at`` is per ``(owner, harness, model,
1954    #: competition)`` because ``llm_calls`` has no run column, so a
1955    #: second run of the same agent in the same event reads the same
1956    #: value. The shelf renders the control per row anyway — a row is
1957    #: where an author is standing — and the copy names the agent
1958    #: rather than the run.
1959    #:
1960    #: ⚠️ **Defaults ``False``** because that is the state an author
1961    #: never asked to leave: a producer that forgot this field would
1962    #: otherwise draw a row as published on a deployment where nothing
1963    #: is. The opposite default is a new claim, and here the claim is
1964    #: somebody's consent.
1965    wall_published: bool = False
1966    #: Why this run's author cannot put it on the wall, or ``""`` when
1967    #: they can.
1968    #:
1969    #: ⚠️ **Composed server-side**, ``mine`` and ``traffic``'s rule:
1970    #: every fact it reads reaches the client some other way, so a
1971    #: component *could* derive it — and then two clients would
1972    #: disagree about whether the control is live, in the invisible
1973    #: direction. Each member needs
1974    #: ``settings:agentRuns.wall.<code>`` copy in both locales; the key
1975    #: is computed at the call site, so neither frontend i18n scanner
1976    #: can see it and ``test_run_wall_copy_parity.py`` pins it from
1977    #: :data:`RUN_WALL_BLOCKED` instead.
1978    wall: str = ""
1979    #: Whether this run's **entrant** has published its full conversation
1980    #: (B2). ``TeamState.transcript_published_at``, the author's second,
1981    #: stronger consent — the wall shows one line, this opens the whole
1982    #: scrubbed conversation. Defaults ``False`` for ``wall_published``'s
1983    #: reason: the state an author never asked to leave.
1984    transcript_published: bool = False
1985    #: Why this run's author cannot publish the full conversation, or
1986    #: ``""`` when they can. Composed server-side, ``wall``'s rule. Its
1987    #: extra member over the wall is ``anonymous``: a conversation
1988    #: de-anonymises, so a pseudonymous/hidden entrant — publishable to
1989    #: the wall under an alias — can never publish a transcript. Each
1990    #: member needs ``settings:agentRuns.transcript.<code>`` copy in both
1991    #: locales; ``test_run_wall_copy_parity.py`` pins it from
1992    #: :data:`RUN_TRANSCRIPT_BLOCKED`.
1993    transcript: str = ""
1994    # --- The atomic task (#2585) -----------------------------------------
1995    #: Where the task is, derived by ``ctfy.core.eval_atomic.derive_stage``
1996    #: from ``status`` and the three timestamps below — never stored.
1997    stage: AtomicStage = AtomicStage.QUEUED
1998    #: Why a queued / starting task is not moving; ``none`` otherwise.
1999    wait_reason: AtomicWaitReason = AtomicWaitReason.NONE
2000    #: The numbers behind a ``match_cap`` / ``competition_priority`` wait,
2001    #: read now (#3466). ``None`` for any other reason, or when the node list
2002    #: could not be read (the page then says the plain reason).
2003    match_room: EvalMatchRoom | None = None
2004    #: 1-based place in the campaign's queue, computed at read time from its
2005    #: pending rows. ``None`` for any task that is not queued.
2006    queue_position: int | None = None
2007    #: Dispatch order inside the campaign (lower goes first).
2008    queue_seq: int = 0
2009    #: When the range was admitted, the agent dispatched, grading began;
2010    #: ``0.0`` = not (yet).
2011    range_started_at_ts: float = 0.0
2012    agent_started_at_ts: float = 0.0
2013    grading_started_at_ts: float = 0.0
2014    #: A person asked to stop this task now and it has not stopped yet.
2015    cancel_requested: bool = False
2016    #: The row this one stands in for (a platform-error retry, a requeue, a
2017    #: rerun of a cancelled task) and why; ``""`` on an original row.
2018    replaces_run_id: str = ""
2019    replace_reason: ReplaceReason = ""
2020    #: Why the platform could not serve this run (#2861), ``""`` unless
2021    #: ``status == "unservable"``. A closed set; the run page renders
2022    #: ``common:error.eval_unservable_<reason>`` in the reader's language.
2023    unservable_reason: EvalUnservableReason = ""
2024    #: Whether a later row took this one's place — it is kept, and is not a
2025    #: sample.
2026    superseded: bool = False
2027    #: The row that took this one's place (the reverse of
2028    #: ``replaces_run_id``); ``""`` when nothing did.
2029    superseded_by_run_id: str = ""
2030    #: The turn limit the run was dispatched with — its campaign's, or the
2031    #: deployment's ``eval_max_turns`` the campaign inherited. ``None`` for a
2032    #: standalone run, whose request is not recorded.
2033    max_turns: int | None = None
2034    #: What the agent reported about itself (claims, see ``EvalRunState``).
2035    turns: int = 0
2036    tool_calls_made: int = 0
2037    stop_reason: str = ""
2038    #: Estimated spend at the model's price; ``None`` for an unpriced or
2039    #: unregistered model, never a ``0`` that reads as free.
2040    cost_usd: float | None = None
2041    #: #2675: the side panel. ⚠️ ``None`` means *this surface did not
2042    #: compose it* — only the single-run read does (``resets``' reason: a
2043    #: shelf page would pay it per row for a panel nobody opened).
2044    panel: EvalRunPanel | None = None
2045    #: #2676: the gateway's in-flight reading for a running run; ``None``
2046    #: when the platform does not know, or the run is not running.
2047    llm_activity: EvalRunLlmActivity | None = None
2048    #: #2930: why a finished run produced no result, beside its exit code;
2049    #: ``None`` for a run that succeeded or has not finished.
2050    failure: EvalRunFailure | None = None

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 = ''
harness_version_id: str = ''
model_id: str = ''
competition_id: str = ''
challenge_id: str = ''
instance_id: str = ''
status: str = ''
created_at_ts: float = 0.0
campaign_id: str = ''
started_at_ts: float = 0.0
finished_at_ts: float = 0.0
owner_user_id: str = ''
owner_display_name: str = ''
mine: bool = False
reader: Literal['author', 'operator', 'team'] = 'team'
precheck: bool = False
authored_challenge_id: str = ''
precheck_phase: Literal['', 'queued', 'box_starting', 'box_ready_wait', 'agent_side_wait', 'agent_node_wait', 'harness_starting', 'agent_running'] = ''
precheck_arm: Literal['', 'writeup', 'exploit', 'blind', 'source'] = ''
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 = ''
agent_internet: Literal['', 'allow', 'deny'] = ''
wall_published: bool = False
wall: str = ''
transcript_published: bool = False
transcript: str = ''
stage: ctfy.core.eval_atomic.AtomicStage = <AtomicStage.QUEUED: 'queued'>
wait_reason: ctfy.core.eval_atomic.AtomicWaitReason = <AtomicWaitReason.NONE: 'none'>
match_room: EvalMatchRoom | None = None
queue_position: int | None = None
queue_seq: int = 0
range_started_at_ts: float = 0.0
agent_started_at_ts: float = 0.0
grading_started_at_ts: float = 0.0
cancel_requested: bool = False
replaces_run_id: str = ''
replace_reason: Literal['', 'retry', 'requeue', 'rerun', 'restart'] = ''
unservable_reason: Literal['', 'no_eligible_node', 'range_failed', 'access_unavailable'] = ''
superseded: bool = False
superseded_by_run_id: str = ''
max_turns: int | None = None
turns: int = 0
tool_calls_made: int = 0
stop_reason: str = ''
cost_usd: float | None = None
panel: EvalRunPanel | None = None
llm_activity: EvalRunLlmActivity | None = None
failure: EvalRunFailure | None = None
class MyFactVerdict(ctfy.core.models.CtfyModel):
633class MyFactVerdict(CtfyModel):
634    """How one version of a team's report described one of its own findings.
635
636    ⚠️ **Only findings the team already holds** (#2030): a grade is built
637    over ``report_facts.build_fact_set`` — the team's own solves on the
638    range — so a milestone it has not reached is never named here, and no
639    answer value is ever carried. ``label`` is the question's own prompt,
640    which the team has already been shown.
641    """
642
643    question_id: str = ""
644    label: str = ""
645    verdict: Verdict = "not_mentioned"

How one version of a team's report described one of its own findings.

⚠️ Only findings the team already holds (#2030): a grade is built over report_facts.build_fact_set — the team's own solves on the range — so a milestone it has not reached is never named here, and no answer value is ever carried. label is the question's own prompt, which the team has already been shown.

question_id: str = ''
label: str = ''
verdict: Literal['accurate', 'inaccurate', 'not_mentioned'] = 'not_mentioned'
class MyFavourites(ctfy.core.models.CtfyModel):
16class MyFavourites(CtfyModel):
17    """Everything the caller has starred, by kind — whole sets, never a page."""
18
19    competitions: list[str] = Field(default_factory=list)
20    challenges: list[str] = Field(default_factory=list)

Everything the caller has starred, by kind — whole sets, never a page.

competitions: list[str] = PydanticUndefined
challenges: list[str] = PydanticUndefined
class MyHarness(ctfy.core.state.models.HarnessState):
499class MyHarness(HarnessState):
500    """One of the caller's own agents, with its admission standing.
501
502    ⚠️ **A wire model over the state model, never a column**: admission
503    depends on hot settings and on the owner's other releases, so storing
504    it would be a verdict that goes stale the moment either moves.
505    """
506
507    admission: HarnessAdmission = Field(default_factory=HarnessAdmission)
508    #: #2608: 1-based place in the probe queue while ``probe_status`` is
509    #: ``queued``, else 0. Read at read time (``harness_probe.queue_position``).
510    probe_position: int = 0
511    #: #3475: the private-registry login this agent pulls with, **without its
512    #: secret**; ``None`` when it pulls anonymously.
513    registry_credential: HarnessRegistryCredentialView | None = None

One of the caller's own agents, with its admission standing.

⚠️ A wire model over the state model, never a column: admission depends on hot settings and on the owner's other releases, so storing it would be a verdict that goes stale the moment either moves.

admission: HarnessAdmission = PydanticUndefined
probe_position: int = 0
registry_credential: HarnessRegistryCredentialView | None = None
class MyLlmConnection(ctfy.core.models.CtfyModel):
1103class MyLlmConnection(CtfyModel):
1104    """One of the caller's model connections: a vendor account and one key (#3487).
1105
1106    ⚠️ **No secret field**, :class:`MyLlmKey`'s rule: the key is write-only.
1107    """
1108
1109    #: The connection's id — the same id ``llm_connection_id`` names.
1110    id: str
1111    #: The vendor account (:attr:`MyLlmKey.account`).
1112    account: str
1113    #: The vendor's name, or the address for an endpoint the catalog does
1114    #: not know.
1115    vendor: str
1116    label: str = ""
1117    fingerprint: str = ""
1118    added_at: str = ""
1119    last_used_at: str = ""
1120    #: Every surface the key is spent at, its own seat first.
1121    surfaces: list[LlmSurfaceStatus] = Field(default_factory=list)
1122    #: The vendor's own list, read with this key, and when. ``[]`` with
1123    #: ``models_code`` ``""`` is never read; a failed read keeps the last
1124    #: good list and its ``models_fetched_at``.
1125    models: list[str] = Field(default_factory=list)
1126    models_code: str = ""
1127    models_fetched_at: str = ""
1128    #: Placeholder hints for a model id where no list could be read —
1129    #: never a restriction.
1130    examples: list[str] = Field(default_factory=list)
1131    #: ``False`` only when every surface's last check refused the key:
1132    #: the wizard disables such a connection. Never-checked is usable.
1133    usable: bool = True
1134    #: Another of the caller's connections holds a *different* key for
1135    #: this vendor account ("two different keys for one vendor").
1136    shares_account: bool = False
1137    #: #3487: the vendor's balance read with this key, and the monthly cap.
1138    balance: LlmConnectionBalance = Field(default_factory=LlmConnectionBalance)
1139    spend_cap: LlmSpendCap = Field(default_factory=LlmSpendCap)

One of the caller's model connections: a vendor account and one key (#3487).

⚠️ No secret field, MyLlmKey's rule: the key is write-only.

id: str = PydanticUndefined
account: str = PydanticUndefined
vendor: str = PydanticUndefined
label: str = ''
fingerprint: str = ''
added_at: str = ''
last_used_at: str = ''
surfaces: list[LlmSurfaceStatus] = PydanticUndefined
models: list[str] = PydanticUndefined
models_code: str = ''
models_fetched_at: str = ''
examples: list[str] = PydanticUndefined
usable: bool = True
shares_account: bool = False
balance: LlmConnectionBalance = PydanticUndefined
spend_cap: LlmSpendCap = PydanticUndefined
class MyLlmConnectionCreate(ctfy.core.models.CtfyModel):
1159class MyLlmConnectionCreate(CtfyModel):
1160    """Save (or rotate) the key for one vendor account.
1161
1162    ⚠️ **No dialect and no address**: the account settles both, so the
1163    form cannot store a key where nothing looks for it.
1164    """
1165
1166    account: str = Field(min_length=1, max_length=300)
1167    api_key: str = Field(min_length=1, max_length=512)
1168    label: str = Field(default="", max_length=64)

Save (or rotate) the key for one vendor account.

⚠️ No dialect and no address: the account settles both, so the form cannot store a key where nothing looks for it.

account: str = PydanticUndefined
api_key: str = PydanticUndefined
label: str = ''
class MyLlmKey(ctfy.core.models.CtfyModel):
863class MyLlmKey(CtfyModel):
864    """One of the caller's own upstream credentials, as they may see it.
865
866    ⚠️ **There is no ``api_key`` field, and its absence is the design.**
867    A user pastes a provider key once; every read after that is a list
868    they can tell apart, never one they can copy — and neither can
869    anybody who reaches this endpoint holding their bearer. A field
870    carrying ``""`` would satisfy every "the secret is not in the
871    response" test while leaving the next writer one line from
872    populating it.
873    """
874
875    id: str = ""
876    flavor: FlavorOrUnset = ""
877    #: #3487: the vendor account this key belongs to — the catalog's
878    #: (``deepseek``), or ``<flavor>@<endpoint>`` for an address the catalog
879    #: does not know. One key serves every surface of its account.
880    account: str = ""
881    label: str = ""
882    base_url: str = ""
883    #: The key's last four characters, so a rotated key is
884    #: distinguishable from its predecessor. The *suffix*: provider keys
885    #: carry a recognisable prefix that says nothing about which key this
886    #: is, and the tail is what every provider's own console shows.
887    fingerprint: str = ""
888    added_at: str = ""
889    #: ``""`` means no run has spent it yet — a real answer, not a
890    #: missing one.
891    last_used_at: str = ""
892    #: What the last probe of this credential found, and when.
893    #:
894    #: ⚠️ **A pair, and both are rendered or neither.** A stored code
895    #: with no instant is the stale-marker trap: a key rejected once and
896    #: rotated since would read ``auth_failed`` for ever with nothing
897    #: saying the reading predates the fix.
898    #:
899    #: ⚠️ Both ``""`` is *never probed*, which is not a tenth verdict —
900    #: ``gateway_unreachable`` already means *a probe happened and this
901    #: deployment could not tell you*, and the two get opposite advice.
902    last_probe_code: str = ""
903    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.

id: str = ''
flavor: Union[ctfy.core.enums.Flavor, Literal['']] = ''
account: str = ''
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):
946class MyLlmKeyCreate(CtfyModel):
947    """A credential as its owner types it.
948
949    ⚠️ The dialect is **not** here: it is the path segment, so a body
950    naming one dialect while the URL names another is unrepresentable
951    rather than a precedence rule somebody has to remember.
952    """
953
954    api_key: str = Field(min_length=1, max_length=512)
955    label: str = Field(default="", max_length=64)
956    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):
906class MyLlmKeyProbe(CtfyModel):
907    """What one press of Test on one of the caller's own keys found.
908
909    ⚠️ **No ``key_id``**, unlike the operator's :class:`LlmProviderProbe`.
910    The client never holds one — it addresses a key by
911    ``(flavor, base_url)``, which is the identity the server keys on —
912    so publishing the derived ``byok:<uuid>:…`` id back would put an
913    internal id shape on the wire for no reader at all. The pair it did
914    send is echoed instead, so a card keying its results by provider can
915    file the answer without trusting request order.
916
917    ⚠️ **``ok`` defaults ``False`` and ``code`` defaults ``""``**, the
918    not-a-new-claim direction: a producer that forgets must not report a
919    credential as working.
920    """
921
922    flavor: FlavorOrUnset = ""
923    base_url: str = ""
924    ok: bool = False
925    #: The bucket to act on. ``ok`` / ``auth_failed`` / ``quota_exceeded``
926    #: / ``unreachable`` / ``bad_request`` from the provider,
927    #: ``no_gateway`` / ``gateway_unreachable`` / ``not_synced`` /
928    #: ``unknown`` from this platform, and ``no_model_registered`` when
929    #: nothing a run could dispatch names this address.
930    #:
931    #: ⚠️ A **code, never a boolean**: each one sends the reader to a
932    #: different field, and one red cross reports them identically.
933    code: str = ""
934    #: The provider's verbatim status; ``0`` when nothing answered.
935    status: int = 0
936    #: The provider's own words, bounded by the gateway.
937    detail: str = ""
938    #: What the model actually said. ⚠️ Empty is a *result* — a 200 that
939    #: generated no text is the shape a status code cannot see.
940    reply: str = ""
941    #: The gateway is running its offline stub, so ``ok`` here proves
942    #: nothing about a real provider. Rendered **beside** the verdict.
943    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):
202class MyRegistrationResponse(CtfyModel):
203    """``GET /competitions/{id}/registration`` — the caller's own dossier.
204
205    Carries the form spec alongside the values so the wizard can render
206    from one round trip, and ``missing_required`` so it can tell the user
207    exactly what still blocks submission without reimplementing the
208    server's rule.
209    """
210
211    competition_id: str
212    form: RegistrationFormSpec
213    registered: bool = False
214    submitted_at: datetime | None = None
215    updated_at: datetime | None = None
216    missing_required: list[str] = Field(default_factory=list)
217
218    real_name: str = ""
219    phone: str = ""
220    mailing_address: str = ""
221    # ⚠️ The same closed set the request carries, not a bare ``str``.
222    # It was ``str`` here while ``RegistrationSubmitRequest.gender``
223    # was a ``Literal`` and the storage column was too — three
224    # layers with the middle one dropping the type, the shape
225    # ``ScopeCatalogEntry.max_level`` already records. A client
226    # reads this and writes that, so the loose middle made the
227    # round trip fail to type-check in the one direction that
228    # matters: `tsc` reported it the moment the wire type stopped
229    # being hand-written on the frontend.
230    gender: Literal["", "male", "female", "other", "undisclosed"] = ""
231    organization: str = ""
232    academic_stage: str = ""
233    grade: str = ""
234    undergrad_school: str = ""
235    specialties: list[str] = Field(default_factory=list)
236
237    # Team-scoped answers, echoed so the captain's wizard round-trips.
238    team_id: str = ""
239    team_name: str = ""
240    team_honors: str = ""
241    team_has_logo: bool = False
242    is_captain: bool = False
243    review_status: RegistrationReviewStatus = "approved"
244    review_note: str = ""
245    #: Whether this verdict keeps the caller's team out of play (#2223).
246    review_blocks_play: bool = False
247    #: The two anonymity answers as stored, so the wizard round-trips.
248    #: Booleans here rather than the request's tri-state: this is what
249    #: *is*, and "the form did not ask" is not a state a stored row can
250    #: be in.
251    anonymous: bool = False
252    team_anonymous: bool = False
253    #: Whether this entrant withholds their organisation here (#2458).
254    hide_organization: bool = False
255    #: Whether to invite this entrant to prove their school (#2293): the saved
256    #: stage reads as a university one, or the saved organisation *is* a
257    #: catalogued institution. Not a requirement -- nothing gates on it.
258    school_prompt: bool = False
259    #: What the caller has already proved, so the prompt can step aside.
260    school: SchoolIdentity = Field(default_factory=SchoolIdentity)

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 = ''
review_blocks_play: bool = False
anonymous: bool = False
team_anonymous: bool = False
hide_organization: bool = False
school_prompt: bool = False
school: SchoolIdentity = PydanticUndefined
class MyReportGrade(ctfy.core.models.CtfyModel):
648class MyReportGrade(CtfyModel):
649    """One graded version of the caller's own report (#2030).
650
651    ⚠️ **No ``unsupported`` count.** That is §3.4's fabrication judgement,
652    which stays with the organisers (``ReportGradeSummary``'s reason); a
653    player reads how their report described what they did, never whether
654    the grader thinks they made something up.
655    """
656
657    report_version: int = 0
658    points: int = 0
659    accurate: int = 0
660    inaccurate: int = 0
661    not_mentioned: int = 0
662    graded_at: datetime | None = None
663    verdicts: list[MyFactVerdict] = Field(default_factory=list)

One graded version of the caller's own report (#2030).

⚠️ No unsupported count. That is §3.4's fabrication judgement, which stays with the organisers (ReportGradeSummary's reason); a player reads how their report described what they did, never whether the grader thinks they made something up.

report_version: int = 0
points: int = 0
accurate: int = 0
inaccurate: int = 0
not_mentioned: int = 0
graded_at: datetime.datetime | None = None
verdicts: list[MyFactVerdict] = PydanticUndefined
class MyReportGradesResponse(ctfy.core.models.CtfyModel):
666class MyReportGradesResponse(CtfyModel):
667    """Every graded version of the caller's report on one range, newest first.
668
669    One row per version — the newest run of each, since an organiser's
670    re-grade of a version replaces what that version is judged to be. The
671    matrix a player reads is ``verdicts`` across these rows.
672    """
673
674    competition_id: str = ""
675    challenge_id: str = ""
676    grades: list[MyReportGrade] = Field(default_factory=list)

Every graded version of the caller's report on one range, newest first.

One row per version — the newest run of each, since an organiser's re-grade of a version replaces what that version is judged to be. The matrix a player reads is verdicts across these rows.

competition_id: str = ''
challenge_id: str = ''
grades: list[MyReportGrade] = PydanticUndefined
class MyReportRow(ctfy.core.models.CtfyModel):
153class MyReportRow(CtfyModel):
154    """One range this team has written up, as the team's own index reads it.
155
156    ⚠️ **Not a narrowed :class:`ReportRosterRow`.** That one answers "who
157    has started" for an organiser and carries a team; this answers "which
158    of my ranges have I written up" and every row is the caller's own, so
159    a team column there would be the same value repeated down the page.
160    """
161
162    challenge_id: str = ""
163    version: int = 0
164    length: int = 0
165    saved_at: datetime | None = None
166    #: Where this range's write-up stands with the report grader.
167    status: ReportGradeStatus = "not_scored"
168    #: 1-based place in the grading queue — **only** while ``status`` is
169    #: ``queued``.
170    #:
171    #: ⚠️ ``0`` is *no position*, and it covers two facts on purpose:
172    #: this report is not queued, and this competition is silent so the
173    #: number is withheld. They are told apart by ``status``, which is
174    #: the field that already has to be read first — and collapsing them
175    #: is what keeps a client from rendering "#0 in queue", which is the
176    #: one output a separate "unknown" member would invite.
177    queue_position: int = 0
178    #: When this team handed this range in, or ``None``.
179    finalized_at: datetime | None = None
180    #: Grading requests this team has spent on this range — the
181    #: numerator behind the button's "2 left".
182    #:
183    #: ⚠️ **Per row, because the quota is.** It is keyed
184    #: ``(team, range)``, so a team that has spent everything on one
185    #: estate still has its full allowance on the next — and a single
186    #: number on the response would say the opposite.
187    grade_requests_used: int = 0
188    #: The current grade's points, or ``None`` (#2030). ⚠️ ``None`` is two
189    #: facts, told apart by the response's ``grades_visible``: no grade yet,
190    #: or a grade this competition does not show its players now.
191    points: int | None = None
192    #: Which version that grade is about — ``0`` with ``points``. Compare it
193    #: with ``version``: a team that saved since reads a judgement of a
194    #: draft it has replaced.
195    graded_version: int = 0
196    #: How many of that grade's findings the report described accurately,
197    #: and how many findings it judged — ``None`` with ``points``. The
198    #: denominator is the team's *own* findings when graded, never the
199    #: range's, so "3/4" says nothing about what the team has not found.
200    accurate: int | None = None
201    findings: int | None = None

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
points: int | None = None
graded_version: int = 0
accurate: int | None = None
findings: int | None = None
class MyReportsResponse(ctfy.core.models.CtfyModel):
204class MyReportsResponse(CtfyModel):
205    """This team's report index for one competition.
206
207    ⚠️ **Only ranges with a saved report appear.** The ranges the team
208    has *not* written up are the competition's challenge list minus
209    these, which the client already holds — and listing them here would
210    mean this route deciding what "the competition's ranges" are, a
211    second answer to a question ``effective_challenge_ids`` already owns.
212    """
213
214    competition_id: str = ""
215    rows: list[MyReportRow] = Field(default_factory=list)
216    #: How many reports are queued for grading across the whole
217    #: competition — the denominator behind ``queue_position``.
218    #:
219    #: ⚠️ **Served, unlike the organiser's, because this client cannot
220    #: count it.** Every row here is the caller's own team, so a total
221    #: derived from them would say "1 of 1" for a team third in a queue
222    #: of twelve.
223    #:
224    #: ⚠️ **Zero in a silent competition**, along with every
225    #: ``queue_position``. How many teams have a report in flight is the
226    #: field's progress, which is the thing that format exists to
227    #: withhold — the same read as ``solves_count`` and ``flag_solves``.
228    #: The per-row ``status`` survives, because "your report is queued"
229    #: is a fact about the caller's own submission and about nobody
230    #: else's.
231    queue_length: int = 0
232    #: Whether this competition shows a team its own report grade now —
233    #: ``core.competitions.own_report_grade_visible`` (#2030). What tells a
234    #: row's ``points=None`` "not graded yet" from "not shown here".
235    grades_visible: bool = False
236    #: The per-range ceiling on hand-pushed grading. ⚠️ **``0`` is
237    #: unlimited**, and it is served rather than restated in the client
238    #: for ``ReportAttachmentsResponse.remaining``'s reason: a ceiling
239    #: spelled in two places is one a client eventually disagrees with,
240    #: and the direction it disagrees in is a disabled button on a
241    #: deployment that had room.
242    #:
243    #: ⚠️ **Not withheld in a silent competition.** It is a fact about
244    #: the caller's own allowance, not about the field — the same split
245    #: that keeps ``status`` while dropping ``queue_position``.
246    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
grades_visible: bool = False
grade_requests_limit: int = 0
class MyResults(ctfy.core.models.CtfyModel):
57class MyResults(CtfyModel):
58    """``GET /me/results``: every event the caller entered, newest first."""
59
60    items: list[SittingResult] = Field(default_factory=list)
61    #: 最好名次: the best placement among the rows others may see (never an
62    #: ``only_you`` row), with its event; ``None`` when there is none.
63    best: SittingResult | None = None
64    #: Whether the profile's 战绩 section is on (``rank_history``, default on).
65    shown_on_profile: bool = True

GET /me/results: every event the caller entered, newest first.

items: list[SittingResult] = PydanticUndefined
best: SittingResult | None = None
shown_on_profile: bool = True
class MyReviewTimeline(ctfy.core.models.CtfyModel):
886class MyReviewTimeline(CtfyModel):
887    """``GET /competitions/{id}/my-team/review-timeline`` (#2524)."""
888
889    competition_id: str
890    team_id: str
891    #: Oldest first.
892    events: list[MyReviewTimelineEvent] = Field(default_factory=list)

GET /competitions/{id}/my-team/review-timeline (#2524).

competition_id: str = PydanticUndefined
team_id: str = PydanticUndefined
events: list[MyReviewTimelineEvent] = PydanticUndefined
class MyReviewTimelineEvent(ctfy.core.models.CtfyModel):
872class MyReviewTimelineEvent(CtfyModel):
873    """One entry of the player's own review timeline (#2524).
874
875    Standing only: no field, no value, and a verdict without its reviewer.
876    """
877
878    id: str
879    at: datetime
880    kind: PlayerTimelineEventKind
881    actor_name: str = ""
882    actor_kind: RegistrationRevisionActorKind
883    reason: str | None = None

One entry of the player's own review timeline (#2524).

Standing only: no field, no value, and a verdict without its reviewer.

id: str = PydanticUndefined
at: datetime.datetime = PydanticUndefined
kind: Literal['submitted', 'verdict_approved', 'verdict_rejected', 'resubmitted', 'new_member_reopened', 'todo_created', 'todo_completed'] = PydanticUndefined
actor_name: str = ''
actor_kind: Literal['entrant', 'captain', 'organiser', 'system'] = PydanticUndefined
reason: str | None = None
class MySolveSummary(ctfy.core.models.CtfyModel):
1097class MySolveSummary(CtfyModel):
1098    """Per-challenge solve summary for the calling **user**.
1099
1100    Returned by ``GET /me/solves``. One row per challenge the caller
1101    personally captured at least one question of. ``best_rank`` is the
1102    smallest rank across those captures — drives the 1血/2血/3血 badge.
1103    Multi-flag challenges may have different ranks per flag; reporting
1104    the best one gives players credit for whichever piece they nailed
1105    first.
1106
1107    ⚠️ **The subject is the person, and this docstring said "team" for
1108    long enough that a client believed it.** The route filters
1109    ``Solve.user_id`` — the human who submitted — while every scoring
1110    surface on this platform keys on ``team_id``, so a team-mate who had
1111    not personally submitted got no row at all and the challenge card
1112    rendered 未解出 over a challenge their team already owned. That is a
1113    profile page's honest answer and a challenge card's wrong one; the
1114    card reads ``ChallengeInfo.my_team_solved_question_ids`` now, and
1115    this stays what it always was.
1116    """
1117
1118    challenge_id: str
1119    best_rank: int
1120    solved_at: datetime | None = None
1121    # Whether **every** capture the team holds on this challenge is on
1122    # the competition's board.
1123    #
1124    # ⚠️ ``all``, not ``any``: the card renders this as "some of what you
1125    # captured here does not count", and a partial split has to say so.
1126    # ``any`` would stay quiet about two uncounted flags out of three,
1127    # which is the same silence as before the field existed.
1128    #
1129    # Unscoped (``competition_id=""``) this is always ``True``: the
1130    # cross-competition history applies no window, so no capture there
1131    # is "not counted" — see ``/me/solves``.
1132    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 MyTaskDetail(ctfy.core.models.CtfyModel):
92class MyTaskDetail(CtfyModel):
93    """Visible task card plus the owner's personal evaluation, when applicable."""
94
95    task: TaskCard
96    campaign: EvalCampaignState | None = None
97    can_cancel: bool = False

Visible task card plus the owner's personal evaluation, when applicable.

task: TaskCard = PydanticUndefined
campaign: ctfy.core.state.models.EvalCampaignState | None = None
can_cancel: bool = False
class MyTeamTodosResponse(ctfy.core.models.CtfyModel):
1167class MyTeamTodosResponse(CtfyModel):
1168    """``GET /competitions/{id}/my-team/todos``: the caller's to-dos and the
1169    team's progress against the verdict standing now (#2527)."""
1170
1171    competition_id: str
1172    team_id: str = ""
1173    team_name: str = ""
1174    is_captain: bool = False
1175    review_status: RegistrationReviewStatus = "approved"
1176    review_note: str = ""
1177    reviewed_at: datetime | None = None
1178    registration_ends_at: datetime | None = None
1179    todos: list[RegistrationTodoInfo] = Field(default_factory=list)
1180    progress: list[TeamTodoProgressRow] = Field(default_factory=list)
1181    total: int = 0
1182    done: int = 0
1183    #: The captain may send a rejected team back to the queue before every
1184    #: to-do is done.
1185    can_resubmit: bool = False
1186    #: When the reviewer's supplement deadline passes (#2767 E): the earliest
1187    #: ``due_at`` of the team's open to-dos while it stands rejected, else
1188    #: ``None``. Once it passes with a to-do open, the team goes back to
1189    #: ``pending`` flagged ``overdue`` (``review_resubmission.reopen_overdue``).
1190    supplement_deadline: datetime | None = None

GET /competitions/{id}/my-team/todos: the caller's to-dos and the team's progress against the verdict standing now (#2527).

competition_id: str = PydanticUndefined
team_id: str = ''
team_name: str = ''
is_captain: bool = False
review_status: Literal['pending', 'approved', 'rejected'] = 'approved'
review_note: str = ''
reviewed_at: datetime.datetime | None = None
registration_ends_at: datetime.datetime | None = None
todos: list[RegistrationTodoInfo] = PydanticUndefined
progress: list[TeamTodoProgressRow] = PydanticUndefined
total: int = 0
done: int = 0
can_resubmit: bool = False
supplement_deadline: datetime.datetime | None = None
class NodeAddress(ctfy.core.models.CtfyModel):
369class NodeAddress(CtfyModel):
370    """``GET /admin/nodes/{node_id}/address`` (#3037): claimed, observed, measured."""
371
372    node_id: str
373    url: str
374    #: The host in ``url`` — the node's claim, or the admin's correction.
375    claimed_host: str
376    url_pinned: bool = False
377    observed_address: str = ""
378    observed_at: datetime | None = None
379    #: ``None`` when it cannot be compared (a name, or nothing observed).
380    mismatch: bool | None = None
381    #: One row per probed surface; a kind with no row has not been probed.
382    udp: list[NodeUdpCheck] = Field(default_factory=list)
383    #: #3047: the platform's last call to the node's health route at ``url``;
384    #: ``None`` = not probed, never *reachable*.
385    control: NodeControlCheck | None = None
386    #: The node beats but the platform cannot call it; ``None`` = unknown.
387    outbound_only: bool | None = None
388    #: What the approve dialog presets off for this node (#3047,
389    #: `core.node_features.OUTBOUND_ONLY_OFF` when outbound-only, else ``[]``)
390    #: — the server's list, so the dialog never spells it out itself.
391    suggested_disabled_features: list[NodeFeature] = Field(default_factory=list)

GET /admin/nodes/{node_id}/address (#3037): claimed, observed, measured.

node_id: str = PydanticUndefined
url: str = PydanticUndefined
claimed_host: str = PydanticUndefined
url_pinned: bool = False
observed_address: str = ''
observed_at: datetime.datetime | None = None
mismatch: bool | None = None
udp: list[NodeUdpCheck] = PydanticUndefined
control: NodeControlCheck | None = None
outbound_only: bool | None = None
suggested_disabled_features: list[typing.Literal['instance_hold', 'instance_internet', 'harness_admission', 'harness_access', 'harness_results', 'agent_ingress', 'harness_ingress_access', 'public_ports']] = PydanticUndefined
class NodeAddressEcho(ctfy.core.models.CtfyModel):
394class NodeAddressEcho(CtfyModel):
395    """``GET /nodes/address-echo`` (#3037): the source address the platform sees."""
396
397    address: str

GET /nodes/address-echo (#3037): the source address the platform sees.

address: str = PydanticUndefined
class NodeApproveRequest(ctfy.core.models.CtfyModel):
328class NodeApproveRequest(CtfyModel):
329    """Body for ``POST /admin/nodes/{node_id}/approve`` (#3037).
330
331    ``profile`` picks the placement labels by the join command's own rule
332    (`ctfy.core.node_join.labels_for_profile`); ``competition_id`` goes with
333    the ``competition`` profile and nothing else. The node's advertised
334    labels (#2082) are kept whatever the profile.
335    """
336
337    profile: NodeProfile = NodeProfile.NORMAL
338    competition_id: str = ""
339    #: The features the admin switches off as it approves (#3037), having
340    #: read what the node supports; ``[]`` approves with every feature on.
341    disabled_features: list[str] = Field(default_factory=list)
342    #: The address the platform dials, as the admin corrects it (#3037);
343    #: ``""`` keeps the node's own claim. Once set it is held: the node's
344    #: re-registration no longer moves it.
345    url: str = ""

Body for POST /admin/nodes/{node_id}/approve (#3037).

profile picks the placement labels by the join command's own rule (ctfy.core.node_join.labels_for_profile); competition_id goes with the competition profile and nothing else. The node's advertised labels (#2082) are kept whatever the profile.

profile: ctfy.core.node_join.NodeProfile = <NodeProfile.NORMAL: 'normal'>
competition_id: str = ''
disabled_features: list[str] = PydanticUndefined
url: str = ''
class NodeAuthoredArchiveLimits(ctfy.core.models.CtfyModel):
60class NodeAuthoredArchiveLimits(CtfyModel):
61    """The authored archive limits a platform asks a node to enforce.
62
63    The platform settings layer may change while a node process is alive.
64    These values therefore ride the authenticated heartbeat response rather
65    than being read once from the node's environment.  The ordering is a
66    safety invariant: the compressed request cannot exceed one file, and one
67    file cannot exceed the total unpacked tree.
68    """
69
70    archive_bytes: int = Field(default=MAX_FILE_BYTES, gt=0)
71    unpacked_bytes: int = Field(default=MAX_TOTAL_BYTES, gt=0)
72    file_bytes: int = Field(default=MAX_FILE_BYTES, gt=0)
73
74    @classmethod
75    def from_config(cls, config: Any) -> NodeAuthoredArchiveLimits:
76        return cls(
77            archive_bytes=int(config.max_authored_challenge_bytes),
78            unpacked_bytes=int(config.max_authored_challenge_total_bytes),
79            file_bytes=int(config.max_authored_challenge_file_bytes),
80        )
81
82    @model_validator(mode="after")
83    def _limits_are_ordered(self) -> NodeAuthoredArchiveLimits:
84        if self.file_bytes > self.unpacked_bytes:
85            raise ValueError("file_bytes must not exceed unpacked_bytes")
86        if self.archive_bytes > self.file_bytes:
87            raise ValueError("archive_bytes must not exceed file_bytes")
88        return self

The authored archive limits a platform asks a node to enforce.

The platform settings layer may change while a node process is alive. These values therefore ride the authenticated heartbeat response rather than being read once from the node's environment. The ordering is a safety invariant: the compressed request cannot exceed one file, and one file cannot exceed the total unpacked tree.

archive_bytes: int = 67108864
unpacked_bytes: int = 134217728
file_bytes: int = 67108864
@classmethod
def from_config(cls, config: Any) -> NodeAuthoredArchiveLimits:
74    @classmethod
75    def from_config(cls, config: Any) -> NodeAuthoredArchiveLimits:
76        return cls(
77            archive_bytes=int(config.max_authored_challenge_bytes),
78            unpacked_bytes=int(config.max_authored_challenge_total_bytes),
79            file_bytes=int(config.max_authored_challenge_file_bytes),
80        )
class NodeAuthoredPush(ctfy.core.models.CtfyModel):
2002class NodeAuthoredPush(CtfyModel):
2003    """Platform → node: put this approved upload in your catalog.
2004
2005    ⚠️ **The bytes travel, and that is the one thing this cannot borrow
2006    from :class:`NodeRangePush`.** That model sends a *scenario* and
2007    argues that files must not cross the node API — right, because the
2008    corpus is mounted on the node and the compiler ships inside it. A
2009    player-authored tree has neither: the files *are* the source, the
2010    same reason `AuthoredChallengeState` stores bytes where
2011    `GeneratedRangeState` stores knobs.
2012
2013    ⚠️ **An archive rather than the stored `content_b64` map**, because
2014    that map is the tree as base64, uncompressed — a POST a third larger
2015    than `MAX_TOTAL_BYTES` at the ceiling — and
2016    because `read_archive` + `plan_extraction` are then the *same*
2017    producers the upload door used, re-asked on the machine where an
2018    escaped member would be an arbitrary write on a worker.
2019    """
2020
2021    challenge_id: str
2022    #: `base64(pack_tree(...))` — a gzipped tar carrying exactly one
2023    #: leading directory, the shape `read_archive` strips.
2024    archive_b64: str = ""
2025    #: `tree_revision` of the tree the archive carries (#1835). The node
2026    #: re-derives it from what it extracted and refuses a disagreement, so
2027    #: the name it later answers a start with is the name of what it holds.
2028    #: ``""`` — what an older platform sends — asserts nothing and is
2029    #: checked against nothing.
2030    revision: str = ""
2031    #: The files the node writes ``0o755`` (#2029) — the platform's resolved
2032    #: answer (`AuthoredChallengeState.executable_paths`). ⚠️ ``None`` is
2033    #: what an older platform sends and means *not said*: the node then
2034    #: reads the archive's member bits, inferring from content when it marks
2035    #: none — never "nothing is executable", which is the defect.
2036    executables: list[str] | None = None

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 = ''
executables: list[str] | None = None
class NodeAuthoredResult(ctfy.core.models.CtfyModel):
2039class NodeAuthoredResult(CtfyModel):
2040    """Node → platform: what this node did with a pushed upload.
2041
2042    ⚠️ ``files`` rather than a bare ``ok``: a tree that wrote zero files
2043    and one that wrote forty both answer "ok", and only the count
2044    separates a real push from an archive that planned nothing.
2045    """
2046
2047    challenge_id: str = ""
2048    ok: bool = False
2049    #: How many files landed. `0` on any refusal.
2050    files: int = 0
2051    #: The revision this node now holds, derived from the files it wrote.
2052    #: ``""`` on any refusal.
2053    revision: str = ""
2054    #: Why not. Empty on success — never a generic failure, because the
2055    #: repairs differ: a refused member is fixed by the author, an
2056    #: unwritable catalog on the node's host.
2057    error: str = ""
2058    #: ⚠️ **The tree rules that refused it, as codes (#2113).** `plan_extraction`
2059    #: ran again on the node, and a refusal it reaches is a fact about the
2060    #: *bytes*: every push of this tree will be refused the same way, so the
2061    #: platform must tell it from a node that is down (shape 1 — a prose
2062    #: ``error`` cannot). Empty for every other refusal and on success; an older
2063    #: node sends none, which reads as a fault worth retrying.
2064    refusals: list[AuthoringRefusal] = Field(default_factory=list)

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 = ''
refusals: list[ctfy.core.authoring_refusals.AuthoringRefusal] = PydanticUndefined
class NodeAuthoredSmokePush(ctfy.core.models.CtfyModel):
2067class NodeAuthoredSmokePush(CtfyModel):
2068    """Platform → node: run this authored challenge's own exploit here.
2069
2070    ⚠️ **The id, not the tree.** Unlike :class:`NodeAuthoredPush` there
2071    is nothing to send: the smoke run is *of the copy this node already
2072    holds*, and a push that carried bytes would be smoking an estate no
2073    player will ever launch.
2074    """
2075
2076    challenge_id: str
2077    #: Seconds. `0` means the node's own default — never "no limit",
2078    #: which on a wedged compose is a worker slot held for ever. A
2079    #: *per-step* budget (the tunnel, ``up``, the exploit); what bounds the
2080    #: run is ``deadline_s``.
2081    timeout_s: int = 0
2082    #: #3158: the one total, in seconds, the node's run may spend — every
2083    #: build, queue wait, ``create``, ``up``, retry, the solution image and
2084    #: the exploit (`ctfy.core.smoke_budget.run_deadline_s`); past it the
2085    #: node stops, tears down and answers ``errored`` / ``timed_out`` with
2086    #: the step it ran out in. The platform waits `platform_wait_s` of it.
2087    #: ``0`` is an older platform that sent none: the node derives it from
2088    #: this push's own ``timeout_s`` and build budget by the same function,
2089    #: never "no deadline".
2090    deadline_s: int = Field(default=0, ge=0)
2091    #: Leave nothing behind (plan P2.2): teardown removes every image the
2092    #: run used, pulled ones included, and the build cache is pruned.
2093    #: Sent for a quarantine node only — whose contract is that one
2094    #: unread tree cannot hand anything to the next — and never for the
2095    #: published pool, where the cache is what keeps a player's launch
2096    #: fast.
2097    reset: bool = False
2098    #: The revision the run must be of (#1835): a node holding another
2099    #: fetches the current tree first, so a smoke never reports on bytes
2100    #: that are no longer the challenge. ``""`` = whatever the node holds.
2101    revision: str = ""
2102    #: #1969: whether the estate may reach the internet. The platform sends
2103    #: ``deny`` on the quarantine pool always — unread code must not be a
2104    #: way out — and elsewhere what the upload's competition would give a
2105    #: player, so a smoke that passes is one that event can solve. ``allow``
2106    #: is what an older platform sends.
2107    internet: InternetAccess = InternetAccess.ALLOW
2108    #: The platform-internal hosts a ``deny`` estate still reaches.
2109    egress_allow_hosts: list[str] = Field(default_factory=list)
2110    #: #2082: how a player of the upload's competition reaches its TCP
2111    #: services. ``vpn`` smokes the estate through the tunnel a player is
2112    #: handed (`core.tcp_access.vpn_spec`); the other two reach the same
2113    #: plain byte stream on the estate's own networks. ``tls_sni`` is what
2114    #: an older platform sends.
2115    tcp_access: TcpAccess = TcpAccess.TLS_SNI
2116    #: The CPU / memory the estate is held to — the player's own budget
2117    #: (``server.authored_resources``), so a smoke that passes is one a
2118    #: player's box can solve. ``None`` from an older platform.
2119    resource_budget: ResourceBudget | None = None
2120    #: #2145: the id the platform will read this run's live feed by
2121    #: (``GET /admin/authored/smoke/{run_id}/progress``) — the smoke task's
2122    #: own id. ``""`` from an older platform: the run is then not watched.
2123    run_id: str = ""
2124    #: ⚠️ #3445: no ``probe`` any more. The AI pre-check's model loop left the
2125    #: node (the source reading is a pre-check run on the harness), so this
2126    #: push never carries one; a node not yet upgraded reads its absence as
2127    #: ``None`` — "plays nothing" — which is what it did for every run but one.
2128    #: #2819 W2: the exploit runs **elsewhere** — on side B, or fenced on this
2129    #: node through its own Traefik (F2) — and reaches the box only through
2130    #: its player entrance. This node starts the estate *routed*, publishes
2131    #: the entrance (``GET …/entrance``), holds the estate and the behaviour
2132    #: sampler until the platform posts the exploit's outcome
2133    #: (``POST …/exploit``), grades it and tears down. ⚠️ ``False`` is an
2134    #: older platform, and this node refuses it for an authored tree
2135    #: (``smoke_unrouted``): the in-estate exploit is gone, never a fallback.
2136    exploit_elsewhere: bool = False
2137    #: #2819 W2: how long, in seconds, the estate is held for that outcome
2138    #: once its entrance is ready; capped by ``deadline_s``. ``0`` = derived
2139    #: from ``timeout_s`` (`node_smoke_hold.hold_seconds`).
2140    exploit_hold_s: int = Field(default=0, ge=0)

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
deadline_s: int = 0
reset: bool = False
revision: str = ''
internet: ctfy.core.enums.InternetAccess = <InternetAccess.ALLOW: 'allow'>
egress_allow_hosts: list[str] = PydanticUndefined
tcp_access: ctfy.core.enums.TcpAccess = <TcpAccess.TLS_SNI: 'tls_sni'>
resource_budget: ctfy.core.resource_budget.ResourceBudget | None = None
run_id: str = ''
exploit_elsewhere: bool = False
exploit_hold_s: int = 0
class NodeAuthoredSmokeResult(ctfy.core.models.CtfyModel):
2222class NodeAuthoredSmokeResult(CtfyModel):
2223    """Node → platform: what the smoke run concluded.
2224
2225    ⚠️ ``verdict`` rather than a bare ``ok``: the four outcomes are four
2226    repairs by different people — see :data:`AuthoredSmokeVerdict`. The
2227    exit code rides along because an ``errored`` verdict on a code we do
2228    not recognise is only actionable if the reader can see the code.
2229    """
2230
2231    challenge_id: str = ""
2232    verdict: AuthoredSmokeVerdict = "errored"
2233    #: `smoke_benchmark`'s own exit code. `-1` when it never ran.
2234    exit_code: int = -1
2235    #: The operator's reason, in the node's words. ⚠️ Never an author's:
2236    #: it names this node's paths, hosts and commands.
2237    error: str = ""
2238    #: The run's steps, bounded and scrubbed on the node (#2024).
2239    #:
2240    #: ⚠️ **``None`` is *not reported*** — what an older node sends, since
2241    #: nodes are deployed after the platform — never an empty log, which
2242    #: would read as a run that did nothing.
2243    log: NodeSmokeLog | None = None
2244    #: ⚠️ #3445: no ``probe`` any more. A node not yet upgraded may still send
2245    #: one (nodes are deployed after the platform); `CtfyModel` ignores a key
2246    #: it does not declare, so that answer is read, never refused with a 422
2247    #: (`test_a_model_plays_each_upload_first.py::TestTheOldNodeLoopIsGone`).
2248    #: #2873: whether this run's build reached the network, as the node ran
2249    #: it. ``""`` is an older node, or a run that carried no build budget.
2250    build_network: SmokeBuildNetwork = ""
2251    #: #3211 PR4: what the estate did while it was up (layer ③).
2252    #: ⚠️ ``None`` is *not reported* — an older node, or a sampler that read
2253    #: nothing — never a run that did nothing. Beside ``log``, never in it:
2254    #: the log is the author's to read, this is the reviewer's.
2255    behaviour: SmokeBehaviour | None = None

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 = ''
log: ctfy.core.smoke_log.NodeSmokeLog | None = None
build_network: Literal['', 'online', 'offline'] = ''
behaviour: ctfy.core.smoke_behaviour.SmokeBehaviour | None = None
class NodeAuthoredSyncLine(ctfy.core.models.CtfyModel):
2336class NodeAuthoredSyncLine(CtfyModel):
2337    """One node's outcome in an authored-upload push.
2338
2339    ⚠️ **Its own model rather than :class:`NodeRangeSyncLine` with an
2340    empty `corpus_commit`.** That field is the whole point of the range
2341    line — two nodes can both succeed and hold different estates, and
2342    only the commit says so — and it has no meaning here, where the
2343    bytes are what travelled. A field that asserts nothing on one of two
2344    users of a shared model is the `CompetitionAccess` trap: one name,
2345    two axes. ``files`` is this push's equivalent witness.
2346
2347    ⚠️ Three states, not two: ``ok``, refused (``error`` from the node),
2348    and unreachable (``error`` from the transport) — the repairs are on
2349    different machines.
2350    """
2351
2352    node_id: str = ""
2353    ok: bool = False
2354    #: How many files the node wrote. `0` on any refusal.
2355    files: int = 0
2356    error: str = ""
2357    #: `NodeAuthoredResult.refusals` — the tree rules that refused the push
2358    #: (#2113). Empty for an unreachable node and for any other refusal.
2359    refusals: list[AuthoringRefusal] = Field(default_factory=list)

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 = ''
refusals: list[ctfy.core.authoring_refusals.AuthoringRefusal] = PydanticUndefined
class NodeCapabilities(ctfy.core.models.CtfyModel):
1332class NodeCapabilities(CtfyModel):
1333    """What this node can do beyond the original instance verbs.
1334
1335    ⚠️ **Asked, never inferred from a version string.** An old node has no
1336    ``/capabilities`` route at all and answers 404, which the platform reads
1337    as ``instance_hold=False``. The default here is False for the same
1338    reason: a field a newer node adds and this model does not know is not a
1339    capability this platform may assume.
1340    """
1341
1342    instance_hold: bool = False
1343    #: #2067: the node takes ``POST /instances/{id}/internet`` — an
1344    #: operator's change to a running box's internet verdict. ``False`` from
1345    #: an older node, which the platform reports as a per-instance failure
1346    #: rather than writing a verdict no node holds.
1347    instance_internet: bool = False
1348    #: #2356: the node takes ``HarnessRunRequest.admission_profile`` **and**
1349    #: has just proved it can hold a run bridge (an empty egress plan
1350    #: written to its host firewall and read back). ``False`` from an older
1351    #: node — which would ignore the field and run the image on an
1352    #: ordinary bridge — and from one whose firewall it cannot write.
1353    harness_admission: bool = False
1354    #: The node's own words when ``harness_admission`` is false.
1355    harness_admission_reason: str = ""
1356    #: #2861 slice B: the node takes the target access the platform hands it
1357    #: (``HarnessRunRequest.target_access`` with ``vpn_config`` or
1358    #: ``target_endpoints``), so the agent may run on a node other than its
1359    #: target's. ``False`` from an older node, which would ignore the fields
1360    #: and read the VPN config off its own disk — wrong for a target that is
1361    #: not there, so the platform keeps such a run on the target's node.
1362    harness_access: bool = False
1363    #: #3021: the node keeps each platform run's harness outcome after the
1364    #: blocking ``POST /harness/run`` that asked for it is gone, and answers
1365    #: ``GET /harness/runs/{id}/result`` — so a writer that restarted while
1366    #: an agent ran can collect the run instead of requeueing it. ``False``
1367    #: from an older node, whose outcome dies with the call.
1368    harness_results: bool = False
1369    #: #3020: this node runs the agent-ingress WireGuard listener and has just
1370    #: proved it can write its plan (``?agent_ingress=true``). ``False`` from
1371    #: an older node, from one with ``agent_ingress_port`` 0, and from one that
1372    #: could not converge; the platform then hands a published target as
1373    #: ``published`` endpoints, as before.
1374    agent_ingress: bool = False
1375    #: The node's own words when ``agent_ingress`` is false.
1376    agent_ingress_reason: str = ""
1377    #: #3020: as the **agent's** node, this node reads
1378    #: ``HarnessRunRequest.target_hosts`` with a ``vpn`` access: it maps the
1379    #: published names onto the tunnel and resolves without an estate zone.
1380    #: ``False`` from an older node, which would bring the tunnel up and leave
1381    #: the names resolving to the public address; the platform then hands the
1382    #: target as ``published`` endpoints, as before.
1383    harness_ingress_access: bool = False

What this node can do beyond the original instance verbs.

⚠️ Asked, never inferred from a version string. An old node has no /capabilities route at all and answers 404, which the platform reads as instance_hold=False. The default here is False for the same reason: a field a newer node adds and this model does not know is not a capability this platform may assume.

instance_hold: bool = False
instance_internet: bool = False
harness_admission: bool = False
harness_admission_reason: str = ''
harness_access: bool = False
harness_results: bool = False
agent_ingress: bool = False
agent_ingress_reason: str = ''
harness_ingress_access: bool = False
class NodeCaptureStoreReport(ctfy.core.models.CtfyModel):
526class NodeCaptureStoreReport(CtfyModel):
527    """What a node's capture store holds, and what its last pass did (#1714).
528
529    The node measures ``<proxy_output_host_root>/`` — the per-instance
530    tcpdump and mitmproxy output it writes and nothing else removes —
531    and sends the latest reading on every heartbeat, because the platform
532    cannot see another host's disk and the admin overview is where an
533    operator looks.
534
535    ⚠️ **``None`` on the heartbeat is "not measured", never zero.** A node
536    from before this field, or one that has not finished its first pass,
537    sends nothing; a zero here is a measurement.
538    """
539
540    #: Unix seconds the store was last walked.
541    measured_at: float = 0.0
542    status: PassStatus = PassStatus.OK
543    #: The capture root on the node's own filesystem; ``""`` with
544    #: ``status=no_store`` (captures live in instance workdirs).
545    root: str = ""
546    instances: int = 0
547    total_bytes: int = 0
548    #: Held by instances no box is running from — the most any window
549    #: can reclaim.
550    finished_bytes: int = 0
551    #: Of those, past the retention window when the pass planned.
552    due_bytes: int = 0
553    #: The window and cap this pass applied. ``None`` is *the platform
554    #: did not say*, which reclaims nothing.
555    retention_s: int | None = None
556    cap_bytes: int | None = None
557    reclaimed_instances: int = 0
558    reclaimed_bytes: int = 0
559    #: Removals that failed (a permission, a vanished file). The rest of
560    #: the pass went ahead; these are retried next pass.
561    failed: int = 0
562    kept: dict[KeepReason, int] = Field(default_factory=dict)
563    skipped: dict[SkipReason, int] = Field(default_factory=dict)
564    #: Directories whose capture the per-instance cap has trimmed, and
565    #: the bytes it dropped — read off each one's truncation marker, so
566    #: this is what the disk says rather than what one process counted.
567    truncated_instances: int = 0
568    truncated_bytes: int = 0
569    #: Live instances still over the cap after every closed segment went
570    #: (only tcpdump's open files remain).
571    over_cap_instances: int = 0
572    #: Why the pass did less than its status suggests, when it did.
573    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):
384class NodeCaptureStoreRow(CtfyModel):
385    """One node's capture store, as its last heartbeat reported it."""
386
387    node_id: str
388    node_name: str = ""
389    #: When the platform received the reading; ``0`` = never, since the
390    #: platform started.
391    received_at: float = 0.0
392    #: ``None`` = not measured (an older node, or its first pass has not
393    #: finished). Never read as an empty store.
394    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 NodeControlCheck(ctfy.core.models.CtfyModel):
359class NodeControlCheck(CtfyModel):
360    """Whether the platform last reached the node at its URL (#3047)."""
361
362    status: ControlCheckStatus
363    reason: str = ""
364    #: The URL that was called.
365    url: str = ""
366    checked_at: datetime | None = None

Whether the platform last reached the node at its URL (#3047).

status: ctfy.core.node_address.ControlCheckStatus = PydanticUndefined
reason: str = ''
url: str = ''
checked_at: datetime.datetime | None = None
class NodeFeatureRow(ctfy.core.models.CtfyModel):
424class NodeFeatureRow(CtfyModel):
425    """One feature of one node: what the node says, what the admin says (#3037).
426
427    ``supported`` is the node's own answer, read live (``None`` when the node
428    could not be asked — *unknown*, never *no*); ``enabled`` is the admin's
429    switch; ``effective`` is both, the value every platform path uses.
430    """
431
432    feature: NodeFeature
433    supported: bool | None = None
434    #: The node's own words when it does not support the feature.
435    reason: str = ""
436    enabled: bool = True
437    effective: bool | None = None

One feature of one node: what the node says, what the admin says (#3037).

supported is the node's own answer, read live (None when the node could not be asked — unknown, never no); enabled is the admin's switch; effective is both, the value every platform path uses.

feature: Literal['instance_hold', 'instance_internet', 'harness_admission', 'harness_access', 'harness_results', 'agent_ingress', 'harness_ingress_access', 'public_ports'] = PydanticUndefined
supported: bool | None = None
reason: str = ''
enabled: bool = True
effective: bool | None = None
class NodeFeatures(ctfy.core.models.CtfyModel):
515class NodeFeatures(CtfyModel):
516    """``GET /admin/nodes/{node_id}/features`` (#3037)."""
517
518    node_id: str
519    #: ``False`` when the node did not answer; every ``supported`` is then
520    #: ``None``. ``reason`` says why.
521    reachable: bool = True
522    reason: str = ""
523    features: list[NodeFeatureRow] = Field(default_factory=list)

GET /admin/nodes/{node_id}/features (#3037).

node_id: str = PydanticUndefined
reachable: bool = True
reason: str = ''
features: list[NodeFeatureRow] = PydanticUndefined
class NodeHeartbeat(ctfy.core.models.CtfyModel):
763class NodeHeartbeat(CtfyModel):
764    """Body for ``POST /nodes/heartbeat``.
765
766    Node reports its live counts + resource utilisation on every beat.
767    The percentages are 0–100; 0 is a safe default for the first beat
768    where psutil hasn't had a prior sample to diff against.
769    """
770
771    id: str
772    running: int = 0
773    capacity: int = 50
774    cpu_percent: float = 0.0
775    memory_percent: float = 0.0
776    # Physical RAM in MiB (not a percentage). Memory-aware admission
777    # needs the absolute size; 0 = not reported, which older node builds
778    # send and which admission reads as "unknown, fall back to slots".
779    memory_total_mb: float = 0.0
780    disk_percent: float = 0.0
781    # Unix seconds when the node sampled the metrics. The server stores
782    # ``received_at - sampled_at`` as one-way latency on the resulting
783    # health sample. Default 0 means "not measured" — older node builds
784    # that haven't been upgraded keep working.
785    sampled_at: float = 0.0
786    # The ctfy version the node itself is running. Empty on a node that
787    # predates the field, which reads as "unknown" and is never a skew —
788    # the same rule ``memory_total_mb``'s 0 already follows, so an
789    # un-upgraded fleet stays quiet instead of alarming on every beat.
790    version: str = ""
791    # What this node is actually running, as two facts `version` cannot
792    # carry. ⚠️ **`""` is *unreadable*, never a mismatch** — see the
793    # same pair on :class:`NodeInfo` for the deployment this was
794    # measured on and why semver is not enough.
795    git_commit: str = ""
796    corpus_commit: str = ""
797    # Base64 X25519 public key for this node's half of an AWD match
798    # tunnel. Bounded here rather than validated: the *shape* is checked
799    # once, at plan time, where a refusal can name the node and the match
800    # it broke (`core/awd_fabric.py`); a second validator on the hottest
801    # node→platform path would be a second answer to one question. The
802    # length cap is not that validator — it stops an unbounded string
803    # reaching the column at all.
804    wireguard_public_key: str = Field(default="", max_length=64)
805    #: Host other nodes dial for the fabric. ⚠️ A host, not a
806    #: ``host:port``: the port is the fabric plan's, and a node
807    #: naming one would be a second answer to a question the plan
808    #: already settles.
809    wireguard_host: str = Field(default="", max_length=255)
810    #: The node's latest capture-store reading (#1714). ``None`` is *not
811    #: measured* — an older node, or one mid-first-pass.
812    capture_store: NodeCaptureStoreReport | None = None
813    #: This node's ``repository:tag -> RepoDigest sha`` for registry-sourced
814    #: images, for the fleet image-consistency check. ⚠️ **Three-valued the
815    #: way ``capture_store`` is**: ``None`` is *did not measure this beat* —
816    #: the scan is hourly, or the daemon was unreadable — and the platform
817    #: keeps the last map; a map (even ``{}``) replaces it. Defaulting to
818    #: ``{}`` would make every beat between hourly scans clear a node's map
819    #: and hide a live divergence.
820    image_digests: dict[str, str] | None = None
821    #: The node's last build-egress sync (#1868). ``None`` is *not
822    #: reported* — an older node, one whose sync has not run yet, or one
823    #: the platform has never answered with ``untrusted_builds`` — and
824    #: keeps whatever the row last held, the keep-last rule ``version``
825    #: follows.
826    build_egress: NodeBuildEgressReport | None = None
827    #: The node's last runtime-egress sync (#1969). ``None`` is *not
828    #: reported* — an older node, or one whose first sync has not run — and
829    #: keeps whatever the row last held.
830    runtime_egress: NodeRuntimeEgressReport | None = None
831    #: The node's last helper-image check (#1875). ``None`` is *not
832    #: reported* — an older node, or one whose first check has not
833    #: finished — and keeps whatever the row last held.
834    helper_images: NodeHelperImagesReport | None = None
835    #: The node's last LLM-relay route check (#2570 review). ``None`` is
836    #: *not reported* — an older node, or one whose first check has not
837    #: run — and keeps whatever the row last held. Every status replaces
838    #: it: a node with no relay that nothing wants sends ``absent`` (with
839    #: instance networks wanting it, ``missing``), which is how a row clears once the
840    #: relay is gone. ⚠️ A node built before ``absent`` sends ``None`` there
841    #: and its row keeps the last value until it is upgraded.
842    llm_relay: NodeLlmRelayReport | None = None
843    #: The node's authored-tree fence (#2654 round-5 review S4). ``None`` is
844    #: *not reported* — an older node, or one that has never fenced
845    #: anything — and keeps whatever the row last held.
846    authored_fence: NodeAuthoredFenceReport | None = None
847    #: The node's corpus floor (#3436). ``None`` is *not reported* — an older
848    #: node, or a platform that has not answered ``corpus_floor`` yet.
849    corpus_floor: NodeCorpusFloorReport | None = None
850    #: The node's ingress peer isolation (#3438). ``None`` is *not reported*.
851    peer_isolation: NodePeerIsolationReport | None = None
852    #: The node's last read of its Traefik's pin (#3065). ``None`` is *not
853    #: reported* — an older node, or a first ingress pass not run — and
854    #: keeps whatever the row last held; every status replaces it.
855    traefik_ingress: NodeTraefikIngressReport | None = None
856    #: The node's last harness-admission proof (#3234). ``None`` is *not
857    #: reported* — an older node, or one never asked to prove it — and
858    #: refuses nothing.
859    harness_admission: NodeHarnessAdmissionReport | None = None
860    #: Where this node's ingress canary answers (#1903) — the public URL a
861    #: player would use for a box here, under the canary's fixed label.
862    #: ``None`` is *not reported* (an older node) and keeps the row's value;
863    #: ``""`` is *this node runs no canary* and clears it. The platform
864    #: dials it only if it is the canary's shape (`canary_address`).
865    ingress_canary_url: str | None = Field(default=None, max_length=512)
866    #: The capability labels this node advertises from its own config
867    #: (#2082, ``NODE_ADVERTISED_LABELS``). ``None`` is an older node and
868    #: keeps the row's labels; a report merges *only those keys* over them —
869    #: anything else in it is ignored, so a node cannot mark its own pool.
870    advertised_labels: dict[str, str] | None = None
871    #: #3428: this node's last reach to each target node the platform asked it
872    #: to probe. ``None`` is *not reported* (an older node, or no round yet)
873    #: and every pair stays *unknown* — never reachable, never blocked.
874    target_reach: list[NodeReachReading] | None = Field(default=None, max_length=64)

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
runtime_egress: ctfy.server.models.node.NodeRuntimeEgressReport | None = None
helper_images: ctfy.server.models.node.NodeHelperImagesReport | None = None
llm_relay: ctfy.server.models.node.NodeLlmRelayReport | None = None
authored_fence: ctfy.server.models.node.NodeAuthoredFenceReport | None = None
corpus_floor: ctfy.server.models.node.NodeCorpusFloorReport | None = None
peer_isolation: ctfy.server.models.node.NodePeerIsolationReport | None = None
traefik_ingress: ctfy.server.models.node.NodeTraefikIngressReport | None = None
harness_admission: ctfy.server.models.node.NodeHarnessAdmissionReport | None = None
ingress_canary_url: str | None = None
advertised_labels: dict[str, str] | None = None
target_reach: list[NodeReachReading] | None = None
class NodeHeartbeatResponse(ctfy.core.models.CtfyModel):
877class NodeHeartbeatResponse(CtfyModel):
878    """Body for ``POST /nodes/heartbeat``'s reply.
879
880    The beat used to answer a bare ``{"status": "ok"}``. It carries the
881    platform's authoritative live set for this node now, because that is
882    the only thing that lets the node's own record store shrink — see
883    :func:`ctfy.server.store_reconcile.retired_records` for what went
884    wrong without it.
885
886    ⚠️ **``None`` and ``[]`` are opposite facts and the difference is the
887    whole feature.** ``None`` means *this platform did not say* — an
888    older build, or a response the node could not read — and authorises
889    nothing. ``[]`` means *nothing is live on you*, which is
890    authoritative and is exactly the case a leaked node is in. Defaulting
891    the field to ``[]`` would make every old platform look like it had
892    just retired the fleet, so the default is ``None`` and the platform
893    always fills it.
894    """
895
896    status: str = "ok"
897    #: Effective authored archive limits from the platform settings layer.
898    #: ``None`` means an older platform did not send this field; the node
899    #: keeps its boot-time values in that case.
900    authored_archive: NodeAuthoredArchiveLimits | None = None
901    #: Instance ids the platform holds live *on this node*. Scoped to the
902    #: node on purpose: an unscoped set would make one node's records
903    #: answerable by another node's placements, and the failure mode of
904    #: getting that backwards is retiring live instances.
905    live_instance_ids: list[str] | None = None
906    #: Whether an operator has marked this node as one player-authored
907    #: code may be built on. The node cannot see the label — it lives on
908    #: the platform's `NodeState` — so it rides the beat, and the node
909    #: turns it into a firewall rule keeping a build away from other
910    #: networks' published container ports.
911    #:
912    #: ⚠️ **Three-valued for `live_instance_ids`' reason, and the third
913    #: value is the whole point.** ``None`` is *this platform did not
914    #: say* — an older build, or a body the node could not read — and
915    #: authorises nothing in either direction: the node neither installs
916    #: nor removes. ``False`` is authoritative and is what gives an
917    #: operator their firewall back when they unmark a node, so it can
918    #: never be conflated with silence.
919    untrusted_builds: bool | None = None
920    #: Whether this node must hold the corpus floor (#3436): the platform's
921    #: DB-resolved ``corpus_floor_enabled``. Three-valued for
922    #: ``untrusted_builds``' reason: ``None`` (an older platform) changes
923    #: nothing; ``False`` is authoritative and is what removes the floor.
924    corpus_floor: bool | None = None
925    #: Whether this node must isolate boxes on the ingress bridge (#3438):
926    #: the DB-resolved ``ingress_peer_isolation_enabled``. ``None`` (an older
927    #: platform) changes nothing; ``False`` is what removes the table.
928    ingress_peer_isolation: bool | None = None
929    #: Match indexes (`awd_matches.network_index`) the platform holds live
930    #: on this node, so the node can reap a ``ctfy-awd-*`` tunnel whose
931    #: match is over. ``None`` / ``[]`` are the same opposite facts as
932    #: ``live_instance_ids``: ``None`` is *this platform did not say* and
933    #: reaps nothing, ``[]`` is authoritative and reaps every match
934    #: tunnel. Scoped to the node, and composed from the same live set
935    #: (dict ∪ durable claims) so a platform mid-re-adoption does not tell
936    #: a node its live match is over — the failure `live_instance_ids`
937    #: was extended to close.
938    live_match_indexes: list[int] | None = None
939    #: How long, in seconds, a challenge image may sit idle on this node
940    #: before its reaper reclaims it — the platform's DB-resolved
941    #: ``challenge_image_retention_s``. The node has no settings of its
942    #: own, so this rides the beat like ``untrusted_builds``.
943    #:
944    #: ⚠️ **Three-valued, and the third value is the whole point.**
945    #: ``None`` is *this platform did not say* — an older build, or a body
946    #: the node could not read — and the node reaps nothing. ``0`` is
947    #: authoritative and means *forever* (the shipped default), so an
948    #: operator who never sets it keeps every image; only a positive value
949    #: reaps. Defaulting to ``None`` rather than ``0`` keeps the two
950    #: opposite facts apart, but here they happen to act the same — both
951    #: leave images alone — so the danger a wrong default would cause is a
952    #: *reap*, which is why the default is the do-nothing one.
953    challenge_image_retention_s: int | None = None
954    #: How long a *finished* instance's capture directory is kept on the
955    #: node, in seconds — the platform's DB-resolved
956    #: ``instance_capture_retention_s``, pushed for
957    #: ``challenge_image_retention_s``'s reason (the node has no settings
958    #: layer). Same three values: ``None`` = did not say (reclaim
959    #: nothing), ``0`` = forever (the shipped default), positive = reap.
960    instance_capture_retention_s: int | None = None
961    #: Per-instance ceiling on a live capture's tcpdump segments, bytes —
962    #: ``instance_capture_max_bytes``. ``None`` = did not say, ``0`` = no
963    #: cap. ⚠️ Every drop it causes is recorded on disk and reported;
964    #: a cap that trims silently is the loss this setting must not be.
965    instance_capture_max_bytes: int | None = None
966    #: #3037: whether this node is still waiting for an admin's approval.
967    #: Three-valued for ``untrusted_builds``' reason: ``None`` is *an older
968    #: platform did not say*; the node only reports it (``/api/v1/health``,
969    #: so ``ctfy-admin node bootstrap`` can say "registered, pending approval"
970    #: rather than fail) and changes nothing on it.
971    approval_pending: bool | None = None
972    #: #3428: the target nodes' web entry points this node probes for its
973    #: reach (``core.fleet_reach.probe_targets``). ``None`` is *an older
974    #: platform did not say*, and the node probes nothing.
975    reach_probes: list[NodeReachProbe] | 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'
authored_archive: NodeAuthoredArchiveLimits | None = None
live_instance_ids: list[str] | None = None
untrusted_builds: bool | None = None
corpus_floor: bool | None = None
ingress_peer_isolation: 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
approval_pending: bool | None = None
reach_probes: list[NodeReachProbe] | None = None
class NodeInfo(ctfy.core.models.CtfyModel):
 98class NodeInfo(CtfyModel):
 99    id: str
100    url: str
101    display_name: str = ""
102    capacity: int = 0
103    running: int = 0
104    is_healthy: bool = True
105    last_heartbeat: datetime | None = None
106    # First successful registration. ``None`` only for rows that
107    # pre-date the field; read-path code in
108    # ``ctfy/server/routes/nodes.py`` falls back to ``last_heartbeat``
109    # so the admin UI never shows "—" for established nodes.
110    registered_at: datetime | None = None
111    labels: dict[str, str] = Field(default_factory=dict)
112    # Latest resource sample reported on heartbeat (0–100).
113    cpu_percent: float = 0.0
114    memory_percent: float = 0.0
115    disk_percent: float = 0.0
116    # Physical RAM in MiB; 0 = not reported yet. Surfaced so the admin
117    # Nodes page can show real headroom rather than a bare percentage.
118    memory_total_mb: float = 0.0
119    # The ctfy version this node last reported. "" = a node build that
120    # predates the field. Surfaced so the admin Nodes page can show a
121    # skew against the platform's own version — the node is the one
122    # boundary shipping structured data between two processes, and a
123    # mismatch there loses fields silently rather than erroring.
124    version: str = ""
125    # What this node is actually running, as two facts `version` cannot
126    # carry. ⚠️ **`version` is semver and two nodes at the same semver
127    # are routinely different machines** — measured on this deployment,
128    # where both workers reported `0.6.8` while one ran an image three
129    # builds older and a corpus a month behind, and neither the nodes
130    # page nor the skew reporter could say so. A challenge scheduled to
131    # the stale one then failed with `Challenge not found on this node`,
132    # intermittently, because placement is what decided which corpus
133    # answered.
134    #
135    # ⚠️ `""` is *unreadable*, never a mismatch: a node build predating
136    # the field, an image without `CTFY_GIT_COMMIT`, or a corpus that is
137    # not a working tree. `corpus_commit_of` states the same rule for
138    # the second one, and every reader here honours it by staying quiet.
139    git_commit: str = ""
140    corpus_commit: str = ""
141    #: #2240: whether ``corpus_commit`` is the platform's own. ``None`` when
142    #: either side is unreadable — *not compared*, never "in step". Set by
143    #: the projection; a ``NodeState`` row carries no such column.
144    corpus_in_step: bool | None = None
145    # What this node last reported it holds, as ``repository:tag ->
146    # RepoDigest sha``, for registry-sourced images only. The platform
147    # compares these across nodes (`core/image_consistency.py`) so a tag
148    # resolving to different content on different workers is surfaced. Empty
149    # for a node that predates the field or has not yet reported; the
150    # comparison ignores an empty map exactly as it ignores an empty digest.
151    image_digests: dict[str, str] = Field(default_factory=dict)
152    # Whether this node's build-egress rules are what the platform asked
153    # for (#1868). ⚠️ ``""`` is *not reported* — a node build predating
154    # the field, or one the platform has not yet answered — and never
155    # *in force*; the nodes page renders it as unknown. The reason is the
156    # node's own words for a failure, empty otherwise.
157    build_egress_status: BuildEgressStatus | Literal[""] = ""
158    build_egress_reason: str = ""
159    # Whether this node can hold a no-internet instance off the internet
160    # (#1969). ⚠️ ``""`` is *not reported* — a node build predating the
161    # field — and the platform places no ``deny`` instance there, exactly as
162    # for ``not_in_force``. The reason is the node's own words, empty
163    # otherwise.
164    runtime_egress_status: RuntimeEgressStatus | Literal[""] = ""
165    runtime_egress_reason: str = ""
166    # Whether every helper image this node is configured with is on its
167    # daemon (#1875). ⚠️ ``""`` is *not reported* and never *present*;
168    # the reason names each missing ref and why it could not be pulled.
169    helper_images_status: HelperImagesStatus | Literal[""] = ""
170    helper_images_reason: str = ""
171    # Whether this node's LLM relay keeps its default route on its own
172    # network (#2570 review). ⚠️ ``""`` is *not reported*, never *pinned*;
173    # while ``not_pinned`` the node refuses every relay join, so LLM starts
174    # and relay runs fail there; ``unreadable`` is a check that could not
175    # read the route (joins refused too, no claim about it); ``absent`` is a
176    # node that runs no relay and needs none; ``missing`` runs none while its
177    # instance networks want one. The reason is the node's verdict. #3445:
178    # it is about *container joins* (boxes, harness agents) only — never
179    # whether the node process itself reaches the relay; nothing asks that.
180    llm_relay_status: LlmRelayStatus | Literal[""] = ""
181    llm_relay_reason: str = ""
182    # The node's authored-tree fence (#2654 round-5 review S4). ⚠️ ``""`` is
183    # *not reported*, never *in force*; ``near_containment`` and
184    # ``containing`` are the node about to pause / pausing every authored
185    # box. The reason is the node's own words.
186    authored_fence_status: AuthoredFenceStatus | Literal[""] = ""
187    authored_fence_reason: str = ""
188    # Whether this node's Traefik can join a player-authored estate's ingress
189    # (#3065). ⚠️ ``""`` is *not reported*, never *pinned*; ``not_pinned`` and
190    # ``absent`` mean every routed authored start there is refused, and
191    # placement skips the node for one. The reason is the operator's text.
192    traefik_ingress_status: TraefikIngressStatus | Literal[""] = ""
193    traefik_ingress_reason: str = ""
194    # An operator's cordon (#1900): this node takes no new instance, build,
195    # pull or patch judge, and keeps running what it already has.
196    cordoned: bool = False
197    # #3037: joined with an invite and not yet approved — takes nothing at
198    # all. A separate state from the cordon (`ctfy.core.node_approval`).
199    awaiting_approval: bool = False
200    # The approval verdict: the approving admin's user id (or
201    # ``migration:0300`` / ``admin-token``) and when; empty while pending.
202    approved_by: str = ""
203    approved_at: datetime | None = None
204    # What the node asked for at its last registration. ``labels`` is what
205    # the platform holds; a re-registration never rewrites its placement
206    # half (#3037).
207    requested_labels: dict[str, str] = Field(default_factory=dict)
208    # The join command's profile, preselected by the approve dialog; ``""``
209    # when the node joined with a plain invite.
210    suggested_profile: NodeProfile | Literal[""] = ""
211    suggested_competition_id: str = ""
212    # The features an admin switched off here (#3037); ``[]`` is every
213    # feature on. ``GET /admin/nodes/{id}/features`` pairs it with what the
214    # node supports.
215    disabled_features: list[str] = Field(default_factory=list)
216    # #3037: the source address seen on the node's last register / beat, its
217    # egress (``""`` = not seen yet), and whether it differs from the host in
218    # ``url`` — ``None`` when that cannot be compared (a name, or unseen),
219    # never ``False`` (`core.node_address.address_mismatch`).
220    observed_address: str = ""
221    observed_at: datetime | None = None
222    address_mismatch: bool | None = None
223    # An admin corrected ``url`` at approval; the node's own claim no longer moves it.
224    url_pinned: bool = False
225    # #3047: whether the platform last reached the node at ``url`` (``None``
226    # = not probed yet), and the reading it makes of a node that beats but
227    # cannot be called — ``None`` when not probed, or when the node is not
228    # beating either (that is *down*), never ``False`` for *unknown*.
229    control_status: ControlCheckStatus | None = None
230    outbound_only: bool | None = None
231    # Whether the node carries the ``heavy_estates`` label in its closed
232    # truthy set (#1900) — large estates are placed here first. Projected
233    # by the platform (`scheduling.node_hosts_heavy_estates`) so no client
234    # re-implements which spellings of the label count: the raw label
235    # still rides ``labels``, and ``heavy_estates=false`` must read *no*.
236    heavy_estates: bool = False
237    # The competition this node is reserved for (#1965 E), read off its
238    # ``competition=<id>`` label by the one rule placement uses
239    # (`scheduling.node_dedicated_to`); ``""`` is a shared node.
240    dedicated_to: str = ""
241    # ⚠️ Whether that reservation is **idle**: the label names no
242    # competition that declares a dedicated pool (a typo, an event not yet
243    # switched on, one deleted), so nothing at all is placed here — the
244    # fail-closed reading, said aloud so an idle machine is not mistaken
245    # for a quiet one. ``false`` on a shared node.
246    dedicated_idle: bool = False
247    #: #2864: the one pool placement reads this node into
248    #: (``scheduling.node_pool``, labels resolved by its precedence), and
249    #: ``up`` / ``stale`` / ``unhealthy`` by ``core.node_pressure.node_health``
250    #: — so the nodes list draws the scheduler's reading rather than parsing
251    #: ``labels`` again. ``None`` is *not projected* (a route that builds a
252    #: ``NodeInfo`` without asking, or an older server), never "ordinary".
253    pool: NodePool | None = None
254    health: NodeHealth | None = None
255    # Where this node's ingress canary answers (#1903), as the node
256    # reported it. ``""`` advertises none.
257    ingress_canary_url: str = ""
258    # The platform's last reading of this node's ingress, dialled from
259    # outside the node (`ctfy.server.ingress_probe`). ⚠️ ``""`` is *not
260    # probed* — no canary advertised, or not reached yet — and never
261    # *ok*; ``not_applicable`` is a pool that routes no player (freeze,
262    # quarantine), projected from the one pool rule, never stored.
263    ingress_status: IngressStatus | Literal[""] = ""
264    ingress_reason: str = ""
265    # The wildcard certificate's ``notAfter`` as served to the probe;
266    # ``None`` over plain http or before a reading.
267    ingress_cert_not_after: datetime | None = None
268    ingress_checked_at: datetime | None = None
269
270    @computed_field
271    @property
272    def drain_state(self) -> DrainState | Literal[""]:
273        """``draining`` while a cordoned node still holds boxes, ``drained``
274        once it holds none, ``""`` while it is in rotation (#2115).
275
276        A ``computed_field``: this is a wire model reaching a client
277        (``TeamState.is_disqualified`` is the plain property on the other
278        axis), and the state is derived from two facts the row already has.
279        """
280        state = drain_state_of(cordoned=self.cordoned, running=self.running)
281        return "" if state is None else state

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
version: str = ''
git_commit: str = ''
corpus_commit: str = ''
corpus_in_step: bool | None = None
image_digests: dict[str, str] = PydanticUndefined
build_egress_status: Union[ctfy.core.enums.BuildEgressStatus, Literal['']] = ''
build_egress_reason: str = ''
runtime_egress_status: Union[ctfy.core.enums.RuntimeEgressStatus, Literal['']] = ''
runtime_egress_reason: str = ''
helper_images_status: Union[ctfy.core.enums.HelperImagesStatus, Literal['']] = ''
helper_images_reason: str = ''
llm_relay_status: Union[ctfy.core.enums.LlmRelayStatus, Literal['']] = ''
llm_relay_reason: str = ''
authored_fence_status: Union[ctfy.core.enums.AuthoredFenceStatus, Literal['']] = ''
authored_fence_reason: str = ''
traefik_ingress_status: Union[ctfy.core.enums.TraefikIngressStatus, Literal['']] = ''
traefik_ingress_reason: str = ''
cordoned: bool = False
awaiting_approval: bool = False
approved_by: str = ''
approved_at: datetime.datetime | None = None
requested_labels: dict[str, str] = PydanticUndefined
suggested_profile: Union[ctfy.core.node_join.NodeProfile, Literal['']] = ''
suggested_competition_id: str = ''
disabled_features: list[str] = PydanticUndefined
observed_address: str = ''
observed_at: datetime.datetime | None = None
address_mismatch: bool | None = None
url_pinned: bool = False
control_status: ctfy.core.node_address.ControlCheckStatus | None = None
outbound_only: bool | None = None
heavy_estates: bool = False
dedicated_to: str = ''
dedicated_idle: bool = False
pool: Optional[Literal['ordinary', 'authored', 'quarantine', 'freeze', 'dedicated', 'eval', 'attacker']] = None
health: Optional[Literal['up', 'stale', 'unhealthy']] = None
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
drain_state: Union[ctfy.core.enums.DrainState, Literal['']]
270    @computed_field
271    @property
272    def drain_state(self) -> DrainState | Literal[""]:
273        """``draining`` while a cordoned node still holds boxes, ``drained``
274        once it holds none, ``""`` while it is in rotation (#2115).
275
276        A ``computed_field``: this is a wire model reaching a client
277        (``TeamState.is_disqualified`` is the plain property on the other
278        axis), and the state is derived from two facts the row already has.
279        """
280        state = drain_state_of(cordoned=self.cordoned, running=self.running)
281        return "" if state is None else state

draining while a cordoned node still holds boxes, drained once it holds none, "" while it is in rotation (#2115).

A computed_field: this is a wire model reaching a client (TeamState.is_disqualified is the plain property on the other axis), and the state is derived from two facts the row already has.

class NodeInviteInfo(ctfy.core.models.CtfyModel):
1097class NodeInviteInfo(CtfyModel):
1098    """Admin-facing summary (``GET /nodes/invites``). No token fields."""
1099
1100    id: str
1101    created_at: datetime | None = None
1102    expires_at: datetime | None = None
1103    status: NodeInviteStatus
1104    consumed_by_node_id: str = ""

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 = ''
class NodePatch(ctfy.core.models.CtfyModel):
310class NodePatch(CtfyModel):
311    """Body for ``PATCH /admin/nodes/{node_id}``.
312
313    Fields are optional — absent fields keep their current value.
314    display_name is the common case; labels allow adding/replacing the
315    full dict (partial label edits need a second round-trip).
316    """
317
318    display_name: str | None = None
319    labels: dict[str, str] | None = None
320    #: ``True`` cordons the node (#1900): it takes nothing new and keeps
321    #: running what it has. ``False`` uncordons it.
322    cordoned: bool | None = None
323    #: The features to switch off, the whole set (#3037); every other feature
324    #: is on. A name that is not a feature is 422 ``node_feature_unknown``.
325    disabled_features: list[str] | 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).

display_name: str | None = None
labels: dict[str, str] | None = None
cordoned: bool | None = None
disabled_features: list[str] | None = None
class NodeRangePush(ctfy.core.models.CtfyModel):
1958class NodeRangePush(CtfyModel):
1959    """Platform → node: build this generated range here.
1960
1961    ⚠️ **The scenario travels, not the estate.** A rendered range is
1962    70-odd files; this is a JSON object, and the node has everything it
1963    needs to compile it — the corpus is mounted there too and the
1964    compiler ships inside the corpus. Pushing files over the node API is
1965    the shape ``gauntlet_verify`` argues against, for the same reason.
1966
1967    ⚠️ **Which is why the corpus commit travels with it.** One scenario
1968    compiled against two corpus commits is two *different* estates
1969    wearing one id: a node serving different services and different
1970    milestones under the id a competition names, with nothing on any
1971    screen saying so. `ctfy.server.node_ranges.apply_range_push` refuses
1972    on a mismatch.
1973    """
1974
1975    range_id: str
1976    #: The stored knobs, exactly as the platform holds them.
1977    scenario: dict[str, Any] = Field(default_factory=dict)
1978    #: The corpus commit the platform compiled this range from. ``""``
1979    #: means the platform could not read one (no git binary, a corpus
1980    #: that is a tarball) — **not** "any commit will do" and never a
1981    #: mismatch, so the node proceeds and reports its own instead.
1982    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):
1985class NodeRangeResult(CtfyModel):
1986    """Node → platform: what this node did with a pushed range.
1987
1988    ⚠️ ``corpus_commit`` rather than a bare ``ok``: two nodes can both
1989    succeed and hold different estates, and only the commit says so.
1990    """
1991
1992    range_id: str = ""
1993    ok: bool = False
1994    #: What this node compiled from. Empty when it could not read one.
1995    corpus_commit: str = ""
1996    #: Why not. Empty on success — never a generic failure, because the
1997    #: repairs differ: a corpus mismatch is fixed on the node's host, a
1998    #: compiler refusal on the scenario.
1999    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):
2362class NodeRangeSyncLine(CtfyModel):
2363    """One node's outcome in a fleet-wide range push.
2364
2365    ⚠️ Three states, not two: ``ok``, refused (``error`` from the node),
2366    and unreachable (``error`` from the transport). A boolean would
2367    render the last two identically, and their repairs are on different
2368    machines.
2369    """
2370
2371    node_id: str = ""
2372    ok: bool = False
2373    corpus_commit: str = ""
2374    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):
2258class NodeRangeVerifyPush(CtfyModel):
2259    """Platform → node: build this scenario, solve it, take it down.
2260
2261    ⚠️ **The scenario travels, not the estate** — the same decision
2262    :class:`NodeRangePush` records, and for the same reason: the corpus
2263    is mounted on the node and the compiler ships inside it. A first
2264    attempt tarred the rendered estate and posted it; that also loses
2265    ``expect_corpus_commit``, which is the whole protection.
2266
2267    ⚠️ **This is a question, not a publication.** Unlike a range *push*
2268    there is no ``range_id``: the node renders into scratch and throws it
2269    away, so a verification that fails cannot leave the estate it just
2270    disproved sitting in the catalog.
2271    """
2272
2273    #: The platform's id for this run, and the handle a cancel names.
2274    #:
2275    #: ⚠️ **Minted by the platform, not the node.** The operator presses
2276    #: cancel on the platform, which must be able to name the run before
2277    #: the node has answered anything — a node-minted id would only exist
2278    #: once the run was over.
2279    run_id: str = ""
2280    #: The studio knobs, exactly as the platform holds them.
2281    scenario: dict[str, Any] = Field(default_factory=dict)
2282    #: The corpus commit the platform previewed against. ``""`` means the
2283    #: platform could not read one — **not** "any commit will do" — so
2284    #: the node proceeds and reports its own, as ``NodeRangePush`` does.
2285    expect_corpus_commit: str = ""
2286    compose_timeout_s: int = 900
2287    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):
2290class NodeRangeVerifyResult(CtfyModel):
2291    """Node → platform: what happened, for the platform to judge.
2292
2293    ⚠️ **Reports; does not judge.** There is no ``passed`` here on
2294    purpose — that compares captures against the milestone count, and a
2295    node deciding it would be the second place that does, which is how
2296    the board and the counters came to disagree elsewhere in this
2297    codebase.
2298    """
2299
2300    #: What this node compiled from. Empty when it could not read one.
2301    corpus_commit: str = ""
2302    #: A refusal before any work: corpus mismatch, or a compiler that
2303    #: would not build these knobs.
2304    error: str = ""
2305    #: ``plan_verification``'s refusal — the estate is too big to build
2306    #: here. Distinct from ``error``: the scenario is fine, this node is
2307    #: not the place.
2308    refusal: str = ""
2309    services: int = 0
2310    milestones: int = 0
2311    memory_mb: int = 0
2312    up_exit_code: int | None = None
2313    #: Tail of the failing step's output; a task row is not a log store.
2314    up_output: str = ""
2315    #: The reference solution's stdout, which is what capture counting
2316    #: reads. Empty when the build never got that far.
2317    solve_stdout: str = ""
2318    solve_exit_code: int | None = None
2319    #: Which step ran out of its budget, if one did.
2320    timed_out: str = ""
2321    #: Set when the operator cancelled, naming where it landed.
2322    #:
2323    #: ⚠️ **Its own field, not an ``error``.** A cancelled run is not a
2324    #: failed one: the range is not disproved, the operator changed their
2325    #: mind, and rendering that as a failure would make the studio report
2326    #: a broken scenario every time somebody pressed the button.
2327    cancelled: str = ""
2328    #: ⚠️ Whether the node really took the estate down. The platform has
2329    #: no other way to see it, and a range left running is the failure
2330    #: this path is most likely to produce — the node tears down in its
2331    #: own ``finally`` so a platform that disconnected still leaves a
2332    #: clean host, but then nobody would know it had happened.
2333    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 NodeReachProbe(ctfy.core.models.CtfyModel):
755class NodeReachProbe(CtfyModel):
756    """One target the platform asks a node to probe (``core.fleet_reach.ReachProbe``)."""
757
758    node_id: str
759    host: str
760    port: int = Field(ge=1, le=65535)

One target the platform asks a node to probe (core.fleet_reach.ReachProbe).

node_id: str = PydanticUndefined
host: str = PydanticUndefined
port: int = PydanticUndefined
class NodeReachReading(ctfy.core.models.CtfyModel):
743class NodeReachReading(CtfyModel):
744    """This node's last probe of one target node's web entry point (#3428)."""
745
746    node_id: str = Field(max_length=128)
747    #: A TCP handshake completed: the path through every firewall holds.
748    reached: bool
749    #: The prober's words when it did not; bounded.
750    detail: str = Field(default="", max_length=200)
751    #: How long before this beat the probe ran, by the node's own clock.
752    probed_s_ago: float = Field(default=0.0, ge=0.0)

This node's last probe of one target node's web entry point (#3428).

node_id: str = PydanticUndefined
reached: bool = PydanticUndefined
detail: str = ''
probed_s_ago: float = 0.0
class NodeRegister(ctfy.core.models.CtfyModel):
91class NodeRegister(CtfyModel):
92    url: str  # e.g. "http://node1:8100"
93    display_name: str  # required; operators pick a human-readable label
94    capacity: int = DEFAULT_NODE_CAPACITY
95    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):
1027class NodeRegisterResponse(NodeInfo):
1028    """Registration response carries the node's bearer token (plaintext).
1029
1030    Returned exactly once, at registration. The same token is used in
1031    both directions:
1032
1033    * **node→platform** (heartbeat, deregister) — node presents it as
1034      the Bearer; platform looks the row up by comparing plaintexts.
1035    * **platform→node** (start/stop/status) — platform presents the
1036      same plaintext on every call; node verifies against its in-memory
1037      copy.
1038
1039    Re-registration rotates the token. The node never persists it on
1040    disk; "restart = re-register = fresh credential".
1041    """
1042
1043    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):
1002class NodeRegistryTicket(CtfyModel):
1003    """Reply for ``POST /nodes/registry-ticket``.
1004
1005    ⚠️ **``ticket == ""`` is a refusal, never an error**, and ``reason``
1006    says which: this deployment runs no registry, it cannot say which
1007    account its images live under, or — the one that is a decision — this
1008    node runs player-authored code. That last node is one the operator has
1009    declared may be taken over (`scheduling.node_runs`), so a credential it
1010    can obtain is a credential its tenants can obtain; it gets none, and
1011    its builds run with an empty registry config. The node then falls back
1012    to its ambient docker config, which is exactly the behaviour before
1013    this existed.
1014    """
1015
1016    #: The password half of a docker login for ``CTFY_REGISTRY_DOMAIN``,
1017    #: or ``""``.
1018    ticket: str = ""
1019    #: Seconds the ticket lives; ``0`` with no ticket.
1020    expires_in: int = 0
1021    #: The account whose repositories it may pull, for the node's log.
1022    namespace: str = ""
1023    #: Why there is no ticket; ``""`` when there is one.
1024    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):
978class NodeRegistryTicketRequest(CtfyModel):
979    """Body for ``POST /nodes/registry-ticket`` (#1820).
980
981    A node about to build or start one of the platform's challenges asks
982    for a short-lived credential to pull the platform's own images (the
983    AWD gamebox bases, the corpus's pinned mirrors) — one per operation.
984    """
985
986    #: The asking node's own id; a node token may only ask for itself.
987    id: str
988    #: How long the operation it is for may run, in seconds. Docker reads
989    #: its credential once, at the start, and presents it again for every
990    #: later pull, so the ticket must outlive the operation. Clamped by
991    #: the platform to ``NAMESPACE_PULL_TICKET_MAX_TTL_S``.
992    ttl_s: int = Field(default=1800, ge=1)
993    #: ``""`` asks for the namespace ticket a challenge build or start
994    #: uses. A repository (``<account>/ctfy-edge-dns``, no host, no tag)
995    #: asks for a pull ticket on **that repository alone**, which the
996    #: platform grants only for one of its own helper images (#1875) —
997    #: and grants to every pool, because a helper is the same image the
998    #: node already runs and is no secret of anybody's.
999    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 NodeSmokeEntrance(ctfy.core.models.CtfyModel):
2143class NodeSmokeEntrance(CtfyModel):
2144    """Node A → platform (#2819 W2): the player's entrance to a held smoke box.
2145
2146    ``pending`` until the estate is up and routed; ``ready`` with what a
2147    player of it is handed; ``gone`` once the run ended (or never held).
2148    """
2149
2150    state: Literal["pending", "ready", "gone"] = "gone"
2151    #: The smoke instance's id on this node: the fenced run's target label
2152    #: (and, for F2 on this very node, where its resolver zones are read).
2153    instance_id: str = ""
2154    #: The node's surface of the estate, routed like a player's box; the
2155    #: platform projects it (``player_surface``) exactly as it does a player's.
2156    surface: AttackSurface | None = None
2157    #: The challenge's declared ``entry_urls`` (the projection's input).
2158    entry_urls: list[str] = Field(default_factory=list)
2159    #: The player's own VPN client config, for an estate a player dials into
2160    #: (engagement, or a box started ``vpn``). ⚠️ A credential: this
2161    #: platform↔node call is the only place it travels.
2162    vpn_config: str = Field(
2163        default="", max_length=16384, repr=False, json_schema_extra={"secret": True}
2164    )
2165    #: The zones the estate's resolver serves, for the sealed tunnel resolver.
2166    estate_zones: list[str] = Field(default_factory=list, max_length=32)
2167    #: ``{tree path: base64(bytes)}``: what a player downloads from this
2168    #: instance — its rendered ``attachments/`` — and the task it is handed
2169    #: (``params/prompt.json``). ⚠️ Never ``params/answer.json`` /
2170    #: ``manifest.json``. ``repr=False``: a per-team file may carry an answer.
2171    files: dict[str, str] = Field(default_factory=dict, repr=False)

Node A → platform (#2819 W2): the player's entrance to a held smoke box.

pending until the estate is up and routed; ready with what a player of it is handed; gone once the run ended (or never held).

state: Literal['pending', 'ready', 'gone'] = 'gone'
instance_id: str = ''
surface: ctfy.core.target.AttackSurface | None = None
entry_urls: list[str] = PydanticUndefined
vpn_config: str = ''
estate_zones: list[str] = PydanticUndefined
files: dict[str, str] = PydanticUndefined
class NodeSmokeExploitOutcome(ctfy.core.models.CtfyModel):
2185class NodeSmokeExploitOutcome(CtfyModel):
2186    """Platform → node A (#2819 W2): what the exploit did, run on side B."""
2187
2188    failure: SmokeExploitFailure = ""
2189    exit_code: int = -1
2190    #: The exploit container's stdout then stderr; scrubbed on node A.
2191    output: str = Field(default="", repr=False)
2192    timed_out: bool = False
2193    #: The operator's sentence for a ``failure``; never the exploit's output.
2194    detail: str = ""
2195    #: Where it ran (``side_b`` node id, or this node for F2).
2196    node_id: str = ""
2197    #: ``side_b`` or ``isolated_node`` (F2).
2198    side: str = ""

Platform → node A (#2819 W2): what the exploit did, run on side B.

failure: Literal['', 'side_b_unavailable', 'side_b_failed', 'entrance_unreachable', 'solution_build_failed'] = ''
exit_code: int = -1
output: str = ''
timed_out: bool = False
detail: str = ''
node_id: str = ''
side: str = ''
class NodeUdpCheck(ctfy.core.models.CtfyModel):
348class NodeUdpCheck(CtfyModel):
349    """One UDP surface's last reading from outside (#3037); see `core.node_address`."""
350
351    kind: UdpProbeKind
352    status: UdpCheckStatus
353    reason: str = ""
354    host: str = ""
355    port: int = 0
356    checked_at: datetime | None = None

One UDP surface's last reading from outside (#3037); see core.node_address.

kind: Literal['agent_ingress', 'wireguard'] = PydanticUndefined
status: ctfy.core.node_address.UdpCheckStatus = PydanticUndefined
reason: str = ''
host: str = ''
port: int = 0
checked_at: datetime.datetime | None = None
class NotificationCategoryInfo(ctfy.core.models.CtfyModel):
41class NotificationCategoryInfo(CtfyModel):
42    id: NotificationCategory
43    #: The server's own bilingual wording (the unsubscribe pages use it). The
44    #: settings page translates by ``id`` and falls back to these.
45    title: str
46    description: str
47    #: Account & security. Not switchable; the API refuses to store a cell.
48    forced: bool
49    #: ``copy``: switching the mail off still leaves a copy in the site's
50    #: inbox. ``email_only``: every template of the category is excluded
51    #: from the inbox, so off means nowhere. Derived from the inbox policy.
52    inbox: Literal["copy", "email_only"]
53    #: ``transactional`` | ``marketing``. A marketing category is off
54    #: until the recipient explicitly turns it on, and a recorded
55    #: opt-out on one cannot be reverted away — so the UI must not
56    #: offer "revert to default" there, which would imply reverting
57    #: could switch it back on.
58    policy: str = "transactional"
59    #: One cell per channel that applies: ``email`` always, ``push`` only for
60    #: a category the inbox keeps a copy of (push rides the inbox).
61    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: Literal['security', 'registration', 'team', 'competition', 'announcement', 'results', 'operations', 'digest', 'authoring', 'messages', 'evals', 'competition_news'] = PydanticUndefined
title: str = PydanticUndefined
description: str = PydanticUndefined
forced: bool = PydanticUndefined
inbox: Literal['copy', 'email_only'] = PydanticUndefined
policy: str = 'transactional'
channels: dict[str, NotificationChannelCell] = PydanticUndefined
class NotificationChannelCell(ctfy.core.models.CtfyModel):
20class NotificationChannelCell(CtfyModel):
21    """One (category, channel) cell, as the settings UI needs it.
22
23    Three values rather than one because storage is sparse: ``effective``
24    is what happens now, ``default`` is what reverting would restore, and
25    ``explicit`` says whether the user chose this or is inheriting it —
26    which is also whether a "revert" control should appear at all.
27    """
28
29    effective: bool
30    explicit: bool
31    default: bool
32    #: How the explicit value was set (``settings``, ``unsubscribe_page``,
33    #: ``unsubscribe_one_click``, ...); ``""`` when nobody set it. Lets the
34    #: settings page say "turned off from an email link" so a person can
35    #: notice an opt-out they did not mean to make.
36    source: str = ""
37    #: When the explicit value was written; ``None`` when not explicit.
38    updated_at: AwareDatetime | None = None

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
source: str = ''
updated_at: pydantic.types.AwareDatetime | None = None
class NotificationPreferenceUpdate(ctfy.core.models.CtfyModel):
68class NotificationPreferenceUpdate(CtfyModel):
69    """PATCH body for one cell."""
70
71    category: str
72    channel: NotificationChannel = "email"
73    enabled: bool

PATCH body for one cell.

category: str = PydanticUndefined
channel: Literal['email', 'push'] = 'email'
enabled: bool = PydanticUndefined
class NotificationPreferencesResponse(ctfy.core.models.CtfyModel):
64class NotificationPreferencesResponse(CtfyModel):
65    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):
30class OAuthProviderInfo(CtfyModel):
31    name: OAuthProviderName
32    enabled: bool
33    authorize_path: str  # relative, e.g. "/api/v1/auth/login/github"
34    # Operator-supplied button label. Empty for the built-in providers,
35    # whose names the client already translates ("Sign in with GitHub").
36    # A generic OIDC provider has no name the client could know — the
37    # only useful label is the one the operator chose ("清华大学统一身份
38    # 认证"), so it travels on the wire rather than living in a bundle.
39    display_name: str = ""
40    # A link-only provider (CARSI) is bound from a signed-in account and is
41    # never a way in. Only ``ProvidersResponse.link_providers`` lists one;
42    # ``providers`` (the sign-in buttons) never does.
43    link_only: bool = False
44    # Where the provider *is* usable when this host does not carry it: the
45    # origin of the host whose credentials exist. ``""`` = usable here (or
46    # nowhere). A browser session is per origin, so the primary can only
47    # point there, not bind on its behalf.
48    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):
522class OnboardingIntentRequest(CtfyModel):
523    """Body for ``POST /me/onboarding`` — what this account came to do.
524
525    ⚠️ **Its own request model, deliberately not a field on
526    :class:`ProfilePatchRequest`.** A profile is what a visitor reads
527    off somebody's page; this is an answer given to the platform, and
528    the two have different audiences, different write paths and
529    different lifetimes. Folding it in would also put it inside the
530    profile-visibility machinery, where every key is a thing somebody
531    can choose to publish.
532
533    ⚠️ The type is the enum, so an unrecognised value is a **422 and no
534    write**. The welcome wizard branches on this string; a stored
535    ``"agent"`` — a plausible typo for ``byo`` — matches no branch and
536    renders a blank card where the guidance was, with nothing reporting
537    it. ``""`` is accepted so a client can put an account back to
538    "never asked".
539    """
540
541    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 PairStanding(ctfy.core.models.CtfyModel):
283class PairStanding(CtfyModel):
284    """One ``harness × model`` combination's standing (#2341, L10).
285
286    The default row of a suite's results page: the combination is what was
287    actually run, so it is the one row that is nobody's merge. The by-model
288    and by-harness views are :class:`ModelStanding` and
289    :class:`HarnessStanding`, which pool several of these.
290
291    ⚠️ ``harness_id`` and ``agent`` name the entrant's own agent, so
292    :func:`~ctfy.server.anonymity.anonymise_eval_leaderboard` relabels or
293    drops this row exactly as it does a harness row.
294    """
295
296    rank: int = 0
297    decided_by: RankMetric | None = None
298    model_id: str = ""
299    model_display_name: str = ""
300    vendor: str = ""
301    #: Blank only when ``vendor`` is blank, as on :class:`ModelStanding`.
302    vendor_display_name: str = ""
303    #: harness registry id; blank = the built-in ctfy harness.
304    harness_id: str = ""
305    #: Blank for the built-in harness: the label is the frontend's to localise.
306    harness_display_name: str = ""
307    agent: AgentEntrantInfo | None = None
308    runs: int = 0
309    challenges_attempted: int = 0
310    challenges_errored: int = 0
311    challenges_solved: int = 0
312    solve_rate: float = 0.0
313    input_tokens: int = 0
314    output_tokens: int = 0
315    total_tokens: int = 0
316    #: ⚠️ ``None`` is unknown, never zero (see :class:`HarnessStanding`).
317    tokens_per_solve: float | None = None
318    #: ``None`` when the model is unpriced or no usage was reported --
319    #: painted "—", never "$0".
320    cost_usd: float | None = None
321    avg_turns: float | None = None
322    max_samples: int = 0
323    #: Every corpus version a counted run was graded against, most runs
324    #: first: one entry on a pinned board, possibly several on "all versions".
325    corpus_shas: list[str] = Field(default_factory=list)
326    last_run_at: AwareDatetime | None = None
327    #: solved / attempted per difficulty.
328    difficulty: list[LeaderboardCell] = Field(default_factory=list)
329    #: per provenance bucket and per tag (#2733), counted as ``difficulty``
330    #: is: the mean over campaigns of the bucket's challenges each one ran.
331    #: ⚠️ A challenge carries several tags, so the tag cells overlap and
332    #: never sum to ``challenges_attempted``.
333    buckets: list[LeaderboardCell] = Field(default_factory=list)
334    tags: list[LeaderboardCell] = Field(default_factory=list)
335    low_sample: bool = False
336    #: #2614 decision 3: the combination is the harness release's image
337    #: digest × model, so two releases of one harness are two rows. ``""``
338    #: for the built-in harness and for a run that names no release.
339    harness_digest: str = ""
340    #: ⚠️ **The rank and the headline are the mean over campaigns, never
341    #: the best one** (``core.eval_board_mean``). ``challenges_solved`` and
342    #: ``solve_rate`` above pool every sample and stay for the coverage
343    #: figures; nothing ranks on or shows them. Standalone runs (no
344    #: campaign) are in no mean; ``standalone_*`` lists them for the details.
345    campaigns: int = 0
346    mean_solved: float = 0.0
347    mean_attempted: float = 0.0
348    mean_solve_rate: float = 0.0
349    #: Each campaign's solve rate, oldest first: the spread, kept visible.
350    campaign_solve_rates: list[float] = Field(default_factory=list)
351    #: #2614 decision 4: who served the model, from each run's recorded
352    #: endpoint (``core.eval_board_mean.served_by``), most runs first.
353    #: Empty when no run recorded one (dispatched before it was recorded).
354    providers: list[str] = Field(default_factory=list)
355    #: #2614 decision 2: why the row is on the self-reported view, every
356    #: reason any of its runs carries. Empty on the official view.
357    off_official: list[OfficialIneligibility] = Field(default_factory=list)
358    #: The row's runs outside any campaign (owner, 2026-10-30): listed in its
359    #: details only, counted per run, and in no figure above but coverage.
360    standalone_runs: int = 0
361    standalone_solved_runs: int = 0
362    standalone_challenges: int = 0
363    #: How many of the manifest's challenges this row measured (#2733): the
364    #: numerator of its coverage, over ``scope.manifest_count``. ⚠️ ``None``
365    #: when the board has no manifest (``scope.manifest_count`` is ``None``),
366    #: never a count over the current catalog relabelled as the version's.
367    manifest_covered: int | None = None

One harness × model combination's standing (#2341, L10).

The default row of a suite's results page: the combination is what was actually run, so it is the one row that is nobody's merge. The by-model and by-harness views are ModelStanding and HarnessStanding, which pool several of these.

⚠️ harness_id and agent name the entrant's own agent, so ~ctfy.server.anonymity.anonymise_eval_leaderboard() relabels or drops this row exactly as it does a harness row.

rank: int = 0
decided_by: Optional[Literal['solved', 'solve_rate', 'tokens_per_solve', 'avg_turns', 'last_run_at', 'id']] = None
model_id: str = ''
model_display_name: str = ''
vendor: str = ''
vendor_display_name: str = ''
harness_id: str = ''
harness_display_name: str = ''
agent: AgentEntrantInfo | None = None
runs: int = 0
challenges_attempted: int = 0
challenges_errored: int = 0
challenges_solved: int = 0
solve_rate: float = 0.0
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
tokens_per_solve: float | None = None
cost_usd: float | None = None
avg_turns: float | None = None
max_samples: int = 0
corpus_shas: list[str] = PydanticUndefined
last_run_at: pydantic.types.AwareDatetime | None = None
difficulty: list[LeaderboardCell] = PydanticUndefined
buckets: list[LeaderboardCell] = PydanticUndefined
tags: list[LeaderboardCell] = PydanticUndefined
low_sample: bool = False
harness_digest: str = ''
campaigns: int = 0
mean_solved: float = 0.0
mean_attempted: float = 0.0
mean_solve_rate: float = 0.0
campaign_solve_rates: list[float] = PydanticUndefined
providers: list[str] = PydanticUndefined
off_official: list[typing.Literal['model_unverifiable', 'model_unrecorded', 'partial_set', 'altered_limits', 'incomplete']] = PydanticUndefined
standalone_runs: int = 0
standalone_solved_runs: int = 0
standalone_challenges: int = 0
manifest_covered: int | None = None
class PasswordAuthInfo(ctfy.core.models.CtfyModel):
51class PasswordAuthInfo(CtfyModel):
52    """Gating flag for the email+password sign-in form on the login page."""
53
54    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):
1614class PatchStepInfo(CtfyModel):
1615    """One stage of a verification, as the node judged it — organiser-only.
1616
1617    The node runs build → boot → each checker action → reference exploit
1618    and returns a result per stage; the platform used to keep only the
1619    single player-facing sentence, so an `inconclusive` was three
1620    different repairs at once (issue #1692). These are on the admin
1621    detail read alone: a checker's ``detail`` can describe the
1622    challenge's internals, which the defender must not see.
1623    """
1624
1625    name: str = ""
1626    ok: bool = False
1627    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):
1479class PatchSubmissionCreate(CtfyModel):
1480    """An AWD+ defence submission, addressed one of two ways.
1481
1482    ``instance_id`` is the handle when a box is running, exactly as on
1483    :class:`SubmissionCreate`: the instance row carries the challenge,
1484    the team and the competition, so the caller repeats none of them.
1485
1486    ⚠️ **But a defence submission does not need a box, and requiring one
1487    made the format's natural order unplayable.** Judging builds from
1488    the pristine tree plus the submitted bytes and never touches the
1489    instance, ``GET /challenges/{id}/defence/source`` is already
1490    challenge-scoped for exactly this reason, and upsolve, virtual
1491    participation and a post-event fix all mean patching with nothing
1492    running. So ``challenge_id`` (plus ``competition_id``, which scopes
1493    which team is credited) addresses it directly.
1494
1495    Naming neither is refused rather than guessed at: falling back to
1496    "the caller's only instance" would silently judge a challenge they
1497    did not name. Naming an instance *and* a different challenge is
1498    refused for the same reason.
1499
1500    **This is not a weaker authorisation than the instance form.** There
1501    is no team on the wire either way — the credited team is resolved
1502    from the caller's own membership, as ``GET /patches`` already does —
1503    so the challenge form has no id to spoof at all.
1504    """
1505
1506    instance_id: str = ""
1507    #: The challenge to defend, when no box is running. Ignored (but
1508    #: cross-checked) when ``instance_id`` is given.
1509    challenge_id: str = ""
1510    #: Which competition credits the patch. Empty means the caller's
1511    #: unscoped team, the same convention ``GET /patches`` follows.
1512    competition_id: str = ""
1513    #: Where the patched bytes come from.
1514    #:
1515    #: ``upload`` carries them in ``files``. ``live`` reads them out of
1516    #: the player's own running box — they SSH in, edit, and submit with
1517    #: no payload at all, which is how an AWD player actually works.
1518    #:
1519    #: Explicit rather than inferred from an empty ``files``: an empty
1520    #: map is also what a client bug produces, and silently switching
1521    #: channel on a bug would grade something the player never meant to
1522    #: send.
1523    source: Literal["upload", "live"] = "upload"
1524    #: ``{challenge-relative path: base64(bytes)}``. Base64 because a
1525    #: patch target may legitimately be binary, and JSON has no way to
1526    #: carry those bytes intact otherwise. Must be empty when
1527    #: ``source="live"``.
1528    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):
1565class PatchSubmissionInfo(CtfyModel):
1566    """A defence submission's status — deliberately without its code.
1567
1568    The stored row keeps the submitted bytes so a verdict can be
1569    re-derived after a checker fix, but a *status* view is polled, and
1570    shipping every patch body on every poll is bulk nobody asked for.
1571    """
1572
1573    id: str
1574    challenge_id: str
1575    competition_id: str
1576    submitted_at: datetime | None = None
1577    #: The paths this submission replaced.
1578    files: list[str] = Field(default_factory=list)
1579    #: ⚠️ The stored column is already `PatchSubmissionStatus`; this
1580    #: copy was a bare `str`, so the client asserted nothing about the
1581    #: value it branches on.
1582    status: PatchSubmissionStatus = "pending"
1583    #: Empty until judged, and empty forever on a submission the
1584    #: platform failed to judge — our outage is not the player's loss.
1585    #: ⚠️ Bare `str` until now, and `DefenceCard` compares it against
1586    #: literals — measured, `row.verdict === "not_fixxed"` compiled with
1587    #: **zero** `tsc` errors. Typed, that typo is a build failure.
1588    verdict: Verdict | Literal[""] = ""
1589    detail: str = ""
1590    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 PersonalisedColumn(ctfy.core.models.CtfyModel):
473class PersonalisedColumn(CtfyModel):
474    """One CSV column with its first row's value (masked when secret)."""
475
476    name: str
477    sample: str = ""
478    role: ColumnRole

One CSV column with its first row's value (masked when secret).

name: str = PydanticUndefined
sample: str = ''
role: Literal['team', 'variable', 'secret', 'ignore'] = PydanticUndefined
class PersonalisedPreview(ctfy.core.models.CtfyModel):
500class PersonalisedPreview(CtfyModel):
501    """What a personalised send would do, before it is sent."""
502
503    columns: list[PersonalisedColumn] = Field(default_factory=list)
504    matched: int = 0
505    unmatched: list[PersonalisedRow] = Field(default_factory=list)
506    duplicates: list[PersonalisedRow] = Field(default_factory=list)
507    #: Teams no row names, by label.
508    teams_without_row: list[str] = Field(default_factory=list)
509    #: Variables the subject or body names that the table has no column for.
510    unknown_variables: list[str] = Field(default_factory=list)
511    #: Secret variables the subject names (refused: a subject is shown in lists).
512    secret_in_subject: list[str] = Field(default_factory=list)
513    threads: int = 0
514    recipients: int = 0
515    emails: int = 0
516    previews: list[PersonalisedPreviewItem] = Field(default_factory=list)
517    silent: bool = False

What a personalised send would do, before it is sent.

columns: list[PersonalisedColumn] = PydanticUndefined
matched: int = 0
unmatched: list[PersonalisedRow] = PydanticUndefined
duplicates: list[PersonalisedRow] = PydanticUndefined
teams_without_row: list[str] = PydanticUndefined
unknown_variables: list[str] = PydanticUndefined
secret_in_subject: list[str] = PydanticUndefined
threads: int = 0
recipients: int = 0
emails: int = 0
previews: list[PersonalisedPreviewItem] = PydanticUndefined
silent: bool = False
class PersonalisedPreviewItem(ctfy.core.models.CtfyModel):
491class PersonalisedPreviewItem(CtfyModel):
492    """What one team would read, secrets masked."""
493
494    team_id: str
495    team_label: str
496    subject: str
497    body: str

What one team would read, secrets masked.

team_id: str = PydanticUndefined
team_label: str = PydanticUndefined
subject: str = PydanticUndefined
body: str = PydanticUndefined
class PersonalisedRow(ctfy.core.models.CtfyModel):
481class PersonalisedRow(CtfyModel):
482    """A row that matched no team, or named a team another row already did."""
483
484    line: int
485    value: str
486    #: The closest team, by the name a message may use; ``""`` for none.
487    suggestion_team_id: str = ""
488    suggestion_label: str = ""

A row that matched no team, or named a team another row already did.

line: int = PydanticUndefined
value: str = PydanticUndefined
suggestion_team_id: str = ''
suggestion_label: str = ''
class PersonalisedSendRequest(ctfy.core.models.CtfyModel):
450class PersonalisedSendRequest(CtfyModel):
451    """An organiser's per-team send from a CSV (``team,ip,user,password``).
452
453    ⚠️ ``csv`` may hold credentials and is never logged, echoed or stored as
454    text: the secret columns are sealed per message on the way in."""
455
456    csv: str = Field(repr=False, max_length=1_000_000)
457    team_column: str = Field(max_length=200)
458    #: Columns whose values are secret (sealed; shown masked until opened).
459    secret_columns: list[str] = Field(default_factory=list, max_length=32)
460    subject: str = Field(max_length=MAX_SUBJECT_CHARS * 2)
461    body: str = Field(max_length=MAX_ORGANISER_CHARS * 2)
462    recipients: PersonalisedRecipients = "team"
463    #: A reminder mail carrying neither the subject nor the text.
464    email: bool = False
465    allow_reply: bool = True
466    #: ``{line: team id}``: the organiser's choice for a row that matched
467    #: nothing (「改为 Nu1L」).
468    overrides: dict[int, str] = Field(default_factory=dict)
469    #: Lines set aside (「跳过」).
470    skip_lines: list[int] = Field(default_factory=list, max_length=2000)

An organiser's per-team send from a CSV (team,ip,user,password).

⚠️ csv may hold credentials and is never logged, echoed or stored as text: the secret columns are sealed per message on the way in.

csv: str = PydanticUndefined
team_column: str = PydanticUndefined
secret_columns: list[str] = PydanticUndefined
subject: str = PydanticUndefined
body: str = PydanticUndefined
recipients: Literal['team', 'captain'] = 'team'
email: bool = False
allow_reply: bool = True
overrides: dict[int, str] = PydanticUndefined
skip_lines: list[int] = PydanticUndefined
class PlaneEnrollRequest(ctfy.core.models.CtfyModel):
400class PlaneEnrollRequest(CtfyModel):
401    """``POST /nodes/plane-enroll`` (#3047): the node's *public* WireGuard key.
402
403    The private half was generated on the node and never leaves it. The
404    shape is checked in the route (``plane_public_key_invalid``), exactly,
405    with no stripping.
406    """
407
408    public_key: str = Field(max_length=64)

POST /nodes/plane-enroll (#3047): the node's public WireGuard key.

The private half was generated on the node and never leaves it. The shape is checked in the route (plane_public_key_invalid), exactly, with no stripping.

public_key: str = PydanticUndefined
class PlaneEnrollResponse(ctfy.core.models.CtfyModel):
411class PlaneEnrollResponse(CtfyModel):
412    """What a node needs to write its ``ctfy0.conf`` and dial the hub (#3047)."""
413
414    #: This node's plane address, allocated from the hub's live peer table.
415    address: str
416    prefix_length: int
417    hub_public_key: str
418    #: The hub's own plane address: the platform's API is on ``:8100`` there.
419    hub_address: str
420    #: The hub's public ``host:port`` — the ``control_plane_hub_endpoint`` setting.
421    hub_endpoint: str

What a node needs to write its ctfy0.conf and dial the hub (#3047).

address: str = PydanticUndefined
prefix_length: int = PydanticUndefined
hub_public_key: str = PydanticUndefined
hub_address: str = PydanticUndefined
hub_endpoint: str = PydanticUndefined
class PlatformComposeRequest(ctfy.core.models.CtfyModel):
210class PlatformComposeRequest(CtfyModel):
211    """A platform administrator writing to one account (#2064)."""
212
213    user_id: str = ""
214    #: Resolved to an account when ``user_id`` is blank.
215    email: str = Field(default="", max_length=320)
216    subject: str = Field(max_length=MAX_SUBJECT_CHARS * 2)
217    body: str = Field(max_length=MAX_ORGANISER_CHARS * 2)
218    send_email: bool = False
219    allow_reply: bool = True

A platform administrator writing to one account (#2064).

user_id: str = ''
email: str = ''
subject: str = PydanticUndefined
body: str = PydanticUndefined
send_email: bool = False
allow_reply: bool = True
class PlatformSettingInfo(ctfy.core.models.CtfyModel):
560class PlatformSettingInfo(CtfyModel):
561    """One row from ``GET /admin/platform-settings``.
562
563    ``value`` is the live resolved value (DB > env > default); the
564    other two fields show what env and the built-in default would
565    resolve to in isolation, so the UI can render a tooltip
566    explaining what a ``DELETE`` would land on. ``source`` is the
567    tier the current value came from.
568    """
569
570    key: str = ""
571    type: str = ""
572    description: str = ""
573    value: Any = None
574    source: SettingSource | Literal[""] = ""
575    env_value: Any = None
576    default_value: Any = None
577    #: Whether the resolved ``value`` is actually being applied.
578    #:
579    #: ⚠️ **A setting can resolve ``True`` and do nothing**, and this
580    #: page is where an operator forms the belief that it is running.
581    #: ``email_verification_required`` is the live case:
582    #: ``enforcement_enabled`` is ``wanted AND the deployment can send
583    #: mail``, because the only self-service way to satisfy the gate is
584    #: a mailed code — so on a deployment with no mail provider the row
585    #: reads ``true`` while every unproved account writes freely. That
586    #: is this repository's second failure shape at operator scale: a
587    #: thing that reports healthy while doing nothing, stably and
588    #: invisibly.
589    #:
590    #: ⚠️ Defaults **True** — the withheld value must never be a new
591    #: claim, and "not in force" is a claim. An older server that omits
592    #: the field says nothing rather than accusing every setting of
593    #: being inert.
594    in_force: bool = True
595    #: Why the value is not in force, as a stable code the admin page
596    #: translates; ``""`` when it is. A code rather than prose because
597    #: the reason has to be readable in both locales, and rendering it
598    #: server-side would put copy on a route that carries none.
599    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):
602class PlatformSettingPatch(CtfyModel):
603    """Body of ``PATCH /admin/platform-settings/{key}``.
604
605    ``value`` is JSON-typed; the resolver's per-key validator decides
606    whether it's accepted.
607    """
608
609    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 PlayerCapacity(ctfy.server.models.RegistrationCapacity):
974class PlayerCapacity(RegistrationCapacity):
975    """:class:`RegistrationCapacity` plus the caller's own standing."""
976
977    #: The caller's team's place in the queue, 1-based; ``0`` = not on it.
978    waitlist_position: int = 0
979    #: Whether the caller's team holds a place.
980    holds_place: bool = False
981    #: Whether the caller asked to be told when a place frees.
982    watching: bool = False

RegistrationCapacity plus the caller's own standing.

waitlist_position: int = 0
holds_place: bool = False
watching: bool = False
class PlayerHintList(ctfy.core.models.CtfyModel):
264class PlayerHintList(CtfyModel):
265    """``GET /competitions/{id}/challenges/{challenge}/hints``."""
266
267    competition_id: str
268    challenge_id: str
269    cost_mode: HintCostMode = "discount"
270    tiers: list[PlayerHintTier] = Field(default_factory=list)
271    #: What the challenge pays now and the cap this team holds on it, for the
272    #: confirmation ("本题最多可得 400 → 320 分"). ``None`` where the board
273    #: prices nothing or the field's progress is withheld (silent, frozen).
274    value_now: int | None = None
275    kept_percent: int = 100
276    #: Whether this team has already finished the challenge — a cap no
277    #: longer applies to it (``core.hint_cost``).
278    solved: bool = False

GET /competitions/{id}/challenges/{challenge}/hints.

competition_id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
cost_mode: Literal['discount', 'fixed', 'record'] = 'discount'
tiers: list[PlayerHintTier] = PydanticUndefined
value_now: int | None = None
kept_percent: int = 100
solved: bool = False
PlayerHintStatus = typing.Literal['locked', 'available', 'open']
class PlayerHintTier(ctfy.core.models.CtfyModel):
236class PlayerHintTier(CtfyModel):
237    """One tier as a player sees it. Content only when ``status == "open"``."""
238
239    id: str
240    tier: int
241    status: PlayerHintStatus
242    #: When it was released, if it was.
243    released_at: datetime | None = None
244    #: For a locked tier released purely by time, that time ("纯定时的层可
245    #: 显示时间"); ``None`` for a conditional tier — the condition and the
246    #: field's progress are never disclosed.
247    scheduled_at: datetime | None = None
248    #: Whether opening it costs anything under this competition's mode.
249    costed: bool = False
250    cost_percent: int = 100
251    cost_points: int = 0
252    #: Whether *this* team opened it (a free tier is open without a view).
253    viewed: bool = False
254    #: ``discount`` mode, a costed tier not yet opened: what the challenge
255    #: would pay this team at most after opening it, at its value now
256    #: ("本题最多可得 400 → 320 分"). ``None`` where there is no value to show.
257    value_after: int | None = None
258    body: str = ""
259    link_url: str = ""
260    attachment_name: str = ""
261    attachment_size: int = 0

One tier as a player sees it. Content only when status == "open".

id: str = PydanticUndefined
tier: int = PydanticUndefined
status: Literal['locked', 'available', 'open'] = PydanticUndefined
released_at: datetime.datetime | None = None
scheduled_at: datetime.datetime | None = None
costed: bool = False
cost_percent: int = 100
cost_points: int = 0
viewed: bool = False
value_after: int | None = None
body: str = ''
attachment_name: str = ''
attachment_size: int = 0
class PlayerShellCreate(ctfy.core.models.CtfyModel):
761class PlayerShellCreate(CtfyModel):
762    """Ask for a shell into your own AWD+ box.
763
764    Note what is *absent*: no container. The admin shell takes one
765    because a super-admin legitimately picks among an instance's
766    containers; accepting one here would hand the caller the choice the
767    endpoint exists to deny them. The platform resolves the target from
768    the challenge's own ``patch.live.service``.
769    """
770
771    #: ``bash`` or ``sh``. A tiny allow-list rather than free text —
772    #: the value becomes the exec'd command on the node.
773    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):
845class PlayerShellTicket(CtfyModel):
846    """A single-use ticket for one shell session.
847
848    Two-step because a browser cannot attach an ``Authorization`` header
849    to a WebSocket upgrade. Worth keeping for terminal clients too: it
850    means the long-lived bearer never rides in a URL.
851    """
852
853    session_id: str
854    #: Burns on first WebSocket use and is never returned again.
855    session_token: str
856    #: Platform-relative; the client makes it absolute against its own
857    #: base URL, since only the client knows the scheme it reached us on.
858    ws_url: str
859    #: The resolved compose container. Empty for the browser, which
860    #: never needs it; the bastion does, because it opens the node
861    #: WebSocket itself rather than going through the platform page.
862    container: str = ""
863    #: Seconds the unburned ticket stays valid.
864    expires_in: int
865    #: The compose service the session will land in. Echoed back so a
866    #: player knows which container they are in without reading the
867    #: challenge's metadata — the platform resolved it, so it says so.
868    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 PrecheckInstanceInfo(ctfy.core.models.CtfyModel):
3066class PrecheckInstanceInfo(CtfyModel):
3067    """The pre-check agent's box: where to knock and what is asked.
3068
3069    ⚠️ Only the questions whose prerequisites the run has solved, like a
3070    player's reveal order (a later prompt is a route hint).
3071    """
3072
3073    status: InstanceStatus
3074    description: str = ""
3075    surface: AttackSurface | None = None
3076    questions: list[PrecheckQuestion] = Field(default_factory=list)
3077    #: What this run is handed besides the box (#3189 A/B): ``writeup`` /
3078    #: ``exploit`` with the author's text in ``reference``, ``blind`` with none.
3079    arm: PrecheckArm = "blind"
3080    reference: str = ""

The pre-check agent's box: where to knock and what is asked.

⚠️ Only the questions whose prerequisites the run has solved, like a player's reveal order (a later prompt is a route hint).

status: ctfy.core.enums.InstanceStatus = PydanticUndefined
description: str = ''
surface: ctfy.core.target.AttackSurface | None = None
questions: list[PrecheckQuestion] = PydanticUndefined
arm: Literal['writeup', 'exploit', 'blind', 'source'] = 'blind'
reference: str = ''
class PrecheckQuestion(ctfy.core.models.CtfyModel):
3057class PrecheckQuestion(CtfyModel):
3058    """A question as the pre-check's agent sees it: ids, prompts, choices."""
3059
3060    id: str
3061    prompt: str
3062    choices: list[str] | None = None
3063    solved: bool = False

A question as the pre-check's agent sees it: ids, prompts, choices.

id: str = PydanticUndefined
prompt: str = PydanticUndefined
choices: list[str] | None = None
solved: bool = False
class PrecheckRerunFinding(ctfy.core.models.CtfyModel):
2969class PrecheckRerunFinding(CtfyModel):
2970    """The reviewer's banner (#3266): the newest re-check of this approved
2971    upload held its safety check, and nobody has released it yet. ⚠️ Evidence
2972    only — the upload is still published; the reviewer releases the hold in
2973    the safety check or takes the upload down by the review decision."""
2974
2975    rerun_id: str
2976    started_at_ts: float = 0.0
2977    #: Who started the re-check, by display name; ``""`` when the account is gone.
2978    started_by: str = ""

The reviewer's banner (#3266): the newest re-check of this approved upload held its safety check, and nobody has released it yet. ⚠️ Evidence only — the upload is still published; the reviewer releases the hold in the safety check or takes the upload down by the review decision.

rerun_id: str = PydanticUndefined
started_at_ts: float = 0.0
started_by: str = ''
class PrecheckRerunInfo(ctfy.core.models.CtfyModel):
3033class PrecheckRerunInfo(CtfyModel):
3034    """The event's 「重新预检已通过的题」 card (#3266)."""
3035
3036    #: Approved uploads with a stored current tree, and those of them no
3037    #: pre-check ever played — the two scopes' counts.
3038    approved: int = 0
3039    unprechecked: int = 0
3040    #: The event's chosen pre-check models; ``0`` is off and refuses a start.
3041    models: int = 0
3042    #: The blind runs each upload gets (A and B always run once).
3043    blind_runs: int = 1
3044    #: One round's cap per upload: turns, and seconds.
3045    version_turns: int = 0
3046    version_seconds: int = 0
3047    #: The event's live isolated nodes, by short name; ``[]`` — the batch waits.
3048    nodes: list[str] = Field(default_factory=list)
3049    #: The newest batch, ``None`` before the first.
3050    latest: PrecheckRerunView | None = None

The event's 「重新预检已通过的题」 card (#3266).

approved: int = 0
unprechecked: int = 0
models: int = 0
blind_runs: int = 1
version_turns: int = 0
version_seconds: int = 0
nodes: list[str] = PydanticUndefined
latest: PrecheckRerunView | None = None
class PrecheckRerunStart(ctfy.core.models.CtfyModel):
3053class PrecheckRerunStart(CtfyModel):
3054    scope: PrecheckRerunScope = "unprechecked"

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.

scope: Literal['unprechecked', 'all'] = 'unprechecked'
class PrecheckRerunSummary(ctfy.core.models.CtfyModel):
2999class PrecheckRerunSummary(CtfyModel):
3000    """A finished re-check's counts (#3266), one per upload."""
3001
3002    #: The safety check needs a person.
3003    held: int = 0
3004    #: A trial solved it.
3005    solvable: int = 0
3006    #: The agent handed the writeup finished without solving it.
3007    writeup_unsolved: int = 0
3008    #: No trial reached a result (out of quota, nothing to run, …).
3009    untried: int = 0
3010    #: Stopped, skipped, or never reached.
3011    not_run: int = 0

A finished re-check's counts (#3266), one per upload.

held: int = 0
solvable: int = 0
writeup_unsolved: int = 0
untried: int = 0
not_run: int = 0
class PrecheckRerunView(ctfy.core.models.CtfyModel):
3014class PrecheckRerunView(CtfyModel):
3015    """One re-check batch as its organisers read it."""
3016
3017    id: str
3018    scope: PrecheckRerunScope
3019    status: Literal["running", "stopped", "done"]
3020    started_at_ts: float = 0.0
3021    #: Display names; ``""`` when the account is gone.
3022    started_by: str = ""
3023    stopped_by: str = ""
3024    ended_at_ts: float = 0.0
3025    #: Uploads in the batch, and how many have ended (done, skipped, stopped).
3026    total: int = 0
3027    finished: int = 0
3028    #: The upload being checked now, by title; ``""`` when none is.
3029    running_title: str = ""
3030    summary: PrecheckRerunSummary = Field(default_factory=PrecheckRerunSummary)

One re-check batch as its organisers read it.

id: str = PydanticUndefined
scope: Literal['unprechecked', 'all'] = PydanticUndefined
status: Literal['running', 'stopped', 'done'] = PydanticUndefined
started_at_ts: float = 0.0
started_by: str = ''
stopped_by: str = ''
ended_at_ts: float = 0.0
total: int = 0
finished: int = 0
running_title: str = ''
summary: PrecheckRerunSummary = PydanticUndefined
class PrecheckRound(ctfy.core.models.CtfyModel):
2951class PrecheckRound(CtfyModel):
2952    """One round of an upload's pre-check runs (#3266): the upload round
2953    (``id == ""``, the runs a passed smoke queued) or a re-check an organiser
2954    started. Staff only — an author's answer carries no rounds."""
2955
2956    #: The re-check batch's id; ``""`` for the upload round.
2957    id: str = ""
2958    kind: Literal["upload", "recheck"] = "upload"
2959    #: 1-based, oldest first, over the rounds that have runs.
2960    number: int = 1
2961    started_at_ts: float = 0.0
2962    #: #3445: a re-check round's source reading, as the white-box row it
2963    #: wrote (grounded, scrubbed; the tab's own shape). ``None`` for the upload
2964    #: round — its reading is the version card's — and for a round that read
2965    #: nothing (yet).
2966    source: ProbeRun | None = None

One round of an upload's pre-check runs (#3266): the upload round (id == "", the runs a passed smoke queued) or a re-check an organiser started. Staff only — an author's answer carries no rounds.

id: str = ''
kind: Literal['upload', 'recheck'] = 'upload'
number: int = 1
started_at_ts: float = 0.0
source: ProbeRun | None = None
class PrecheckRunRow(ctfy.core.models.CtfyModel):
2876class PrecheckRunRow(CtfyModel):
2877    """One managed-eval run of a pending version, as its audience reads it.
2878
2879    ⚠️ **No token, no handle, no error text, no node.** ``error`` is the
2880    operator's (node output, command lines); ``stop_reason`` is the closed
2881    reading a person can be shown. The writeup, the submissions and the
2882    archive are what the agent did — the reviewers', organisers' and admins'
2883    only (#3260 superseding #3189 decision 6's author reading): ``None`` in
2884    the author's answer, never a player's.
2885    """
2886
2887    id: str
2888    version_id: str
2889    #: The version's proposal, ``""`` for the upload's own tree — the key the
2890    #: tab matches a run to its ``ProbeVersion`` by (#3189 UI).
2891    revision_id: str = ""
2892    model_id: str
2893    #: The registry row's display name; ``""`` when the row is gone.
2894    model: str = ""
2895    #: What the run was handed (#3189 A/B): the writeup (A), the reference
2896    #: exploit (B), or nothing (``blind``). #3445: ``source`` is the source
2897    #: reading — the tree and no box; its findings are the tab's white-box row.
2898    arm: PrecheckArm = "blind"
2899    status: EvalRunStatus
2900    solved: list[str] = Field(default_factory=list)
2901    fully_solved: bool = False
2902    stop_reason: str = ""
2903    turns: int = 0
2904    started_at_ts: float = 0.0
2905    finished_at_ts: float = 0.0
2906    #: The agent's own final account of what it did. ⚠️ Staff only (#3260,
2907    #: owner decision 2026-11-03): ``None`` for the author, ``""`` when the
2908    #: agent wrote none.
2909    writeup: str | None = None
2910    #: Every answer the agent submitted, oldest first. ⚠️ ``None`` for the
2911    #: author, like :attr:`writeup`; ``[]`` is a run that submitted nothing.
2912    submissions: list[PrecheckSubmissionRow] | None = None
2913    #: What became of the run's archive (``EvalRunState.artifact_status``) —
2914    #: ``stored`` / ``truncated`` mean the staff's download door
2915    #: (``…/precheck-runs/{run_id}/artifact``) serves it. ⚠️ ``None`` for the
2916    #: author: the archive is the agent's working directory.
2917    artifact: EvalRunArtifactStatus | None = None
2918    artifact_bytes: int | None = None
2919    #: What the run spent, for the organisers and reviewers. ⚠️ ``None`` for
2920    #: the author, like the budget (#3053): never ``0``, which is a claim.
2921    input_tokens: int | None = None
2922    output_tokens: int | None = None
2923    #: How many runs the dispatch that wrote this run planned for its version
2924    #: — the *n* of 「第 k / n 步」, as it was when the plan was made. ``0``
2925    #: for a run written before the count was recorded (the reader falls back
2926    #: to :attr:`PrecheckRunsInfo.planned_runs`).
2927    planned_runs: int = 0
2928    #: How many questions the version's stored ``metadata.yaml`` serves — the
2929    #: *y* of a chip's ``x/y``. The count, never the prompts: the author and
2930    #: the reviewers may both read it. ``0`` when the tree is not stored.
2931    question_count: int = 0
2932    #: #3266: the round this run belongs to (:attr:`PrecheckRound.id`);
2933    #: ``""`` is the upload round, the only one an author is ever sent.
2934    round_id: str = ""
2935    #: #3286: an unfinished run with no box yet whose event has no isolated
2936    #: node that can route the challenge (Traefik) — waiting, not stuck.
2937    #: Read off the same placement the dispatch asks, at read time.
2938    waiting: PrecheckWait = ""
2939    #: #3446: where the run is, or the step it stopped in — so a run that
2940    #: ended with no turn says whether the box, the agent's node or the agent
2941    #: itself never got going. ⚠️ Staff only, like the archive: ``None`` for
2942    #: the author; ``""`` for a run written before it was recorded.
2943    phase: Literal["", PrecheckPhase] | None = None
2944    #: #3446: the model calls the LLM gateway relayed for this run — counted,
2945    #: never the agent's claim, so it is there even when the node stopped the
2946    #: agent before it wrote its rollup (``turns`` then reads 0). ⚠️ Staff
2947    #: only: ``None`` for the author.
2948    model_calls: int | None = None

One managed-eval run of a pending version, as its audience reads it.

⚠️ No token, no handle, no error text, no node. error is the operator's (node output, command lines); stop_reason is the closed reading a person can be shown. The writeup, the submissions and the archive are what the agent did — the reviewers', organisers' and admins' only (#3260 superseding #3189 decision 6's author reading): None in the author's answer, never a player's.

id: str = PydanticUndefined
version_id: str = PydanticUndefined
revision_id: str = ''
model_id: str = PydanticUndefined
model: str = ''
arm: Literal['writeup', 'exploit', 'blind', 'source'] = 'blind'
status: Literal['pending', 'running', 'succeeded', 'failed', 'cancelled', 'errored', 'aborted', 'incomplete', 'unservable'] = PydanticUndefined
solved: list[str] = PydanticUndefined
fully_solved: bool = False
stop_reason: str = ''
turns: int = 0
started_at_ts: float = 0.0
finished_at_ts: float = 0.0
writeup: str | None = None
submissions: list[PrecheckSubmissionRow] | None = None
artifact: Optional[Literal['', 'stored', 'empty', 'truncated', 'capture_failed', 'upload_failed', 'not_configured', 'reclaimed']] = None
artifact_bytes: int | None = None
input_tokens: int | None = None
output_tokens: int | None = None
planned_runs: int = 0
question_count: int = 0
round_id: str = ''
waiting: Literal['', 'routable_node'] = ''
phase: Optional[Literal['', 'queued', 'box_starting', 'box_ready_wait', 'agent_side_wait', 'agent_node_wait', 'harness_starting', 'agent_running']] = None
model_calls: int | None = None
class PrecheckRunsInfo(ctfy.core.models.CtfyModel):
2981class PrecheckRunsInfo(CtfyModel):
2982    """Every pre-check run of one upload's versions, newest first."""
2983
2984    runs: list[PrecheckRunRow] = Field(default_factory=list)
2985    #: How many runs one version's pre-check plans under the event's setting
2986    #: now — A, B and the blind runs. ``0`` when the upload has no event.
2987    #: ⚠️ Only the fallback for a run that recorded no plan of its own
2988    #: (:attr:`PrecheckRunRow.planned_runs`): a version dispatched under an
2989    #: older setting planned another count.
2990    planned_runs: int = 0
2991    #: #3266: the rounds the runs belong to (``PrecheckRunRow.round_id``).
2992    #: ⚠️ ``None`` for the author, whose answer has the upload round only.
2993    rounds: list[PrecheckRound] | None = None
2994    #: #3266: the banner for a re-check that held the safety check; ``None``
2995    #: when there is none, and always for the author.
2996    rerun_finding: PrecheckRerunFinding | None = None

Every pre-check run of one upload's versions, newest first.

runs: list[PrecheckRunRow] = PydanticUndefined
planned_runs: int = 0
rounds: list[PrecheckRound] | None = None
rerun_finding: PrecheckRerunFinding | None = None
class PrecheckSourceInfo(ctfy.core.models.CtfyModel):
3083class PrecheckSourceInfo(CtfyModel):
3084    """What a source-reading run is handed (#3445): one prompt, no box.
3085
3086    ⚠️ Built by the platform from the version's stored tree
3087    (``core.precheck_source``), static answers withheld, every untrusted part
3088    fenced by a nonce minted for this answer; served only to a run whose arm
3089    is ``source``.
3090    """
3091
3092    system: str
3093    prompt: str

What a source-reading run is handed (#3445): one prompt, no box.

⚠️ Built by the platform from the version's stored tree (core.precheck_source), static answers withheld, every untrusted part fenced by a nonce minted for this answer; served only to a run whose arm is source.

system: str = PydanticUndefined
prompt: str = PydanticUndefined
class PrecheckSubmission(ctfy.core.models.CtfyModel):
3096class PrecheckSubmission(CtfyModel):
3097    question_id: str = Field(min_length=1, max_length=200)
3098    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.

question_id: str = PydanticUndefined
answer: str | list[str] = PydanticUndefined
class PrecheckSubmissionResult(ctfy.core.models.CtfyModel):
3101class PrecheckSubmissionResult(CtfyModel):
3102    """The verdict, and the run's own record — there is no solve row."""
3103
3104    result: PrecheckGrade
3105    solved: list[str] = Field(default_factory=list)
3106    fully_solved: bool = False

The verdict, and the run's own record — there is no solve row.

result: Literal['correct', 'wrong', 'already_solved', 'unknown_question', 'prerequisite_locked', 'not_one_value', 'locked', 'judge_unsupported'] = PydanticUndefined
solved: list[str] = PydanticUndefined
fully_solved: bool = False
class PrecheckSubmissionRow(ctfy.core.models.CtfyModel):
2864class PrecheckSubmissionRow(CtfyModel):
2865    """One answer a pre-check run's agent submitted, as its staff read it
2866    (#3260). ⚠️ Never in an author's answer: :attr:`PrecheckRunRow.submissions`
2867    is ``None`` there."""
2868
2869    question_id: str
2870    #: Verbatim: one string, or a multi-select question's list.
2871    answer: str | list[str]
2872    verdict: PrecheckGrade
2873    submitted_at_ts: float = 0.0

One answer a pre-check run's agent submitted, as its staff read it (#3260). ⚠️ Never in an author's answer: PrecheckRunRow.submissions is None there.

question_id: str = PydanticUndefined
answer: str | list[str] = PydanticUndefined
verdict: Literal['correct', 'wrong', 'already_solved', 'unknown_question', 'prerequisite_locked', 'not_one_value', 'locked', 'judge_unsupported'] = PydanticUndefined
submitted_at_ts: float = 0.0
class PrewarmNodeResult(ctfy.core.models.CtfyModel):
2771class PrewarmNodeResult(CtfyModel):
2772    node_id: str
2773    #: Challenges handed to the node by this pass.
2774    queued: int = 0
2775    #: Why the node was handed nothing, else empty.
2776    skipped: 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.

node_id: str = PydanticUndefined
queued: int = 0
skipped: str = ''
class PrewarmRow(ctfy.core.models.CtfyModel):
2723class PrewarmRow(CtfyModel):
2724    """One challenge's images on one node, read off what the node reports (#2129).
2725
2726    Organiser-only: ``node_id`` names a machine, which a player must never
2727    learn. ``reason`` is the node's own words for a failure, else empty.
2728    """
2729
2730    challenge_id: str
2731    node_id: str
2732    state: PrewarmState
2733    reason: str = ""

One challenge's images on one node, read off what the node reports (#2129).

Organiser-only: node_id names a machine, which a player must never learn. reason is the node's own words for a failure, else empty.

challenge_id: str = PydanticUndefined
node_id: str = PydanticUndefined
state: ctfy.core.enums.PrewarmState = PydanticUndefined
reason: str = ''
class PrewarmRunResult(ctfy.core.models.CtfyModel):
2779class PrewarmRunResult(CtfyModel):
2780    """What one pre-warm pass did, per node."""
2781
2782    competition_id: str
2783    nodes: list[PrewarmNodeResult] = Field(default_factory=list)

What one pre-warm pass did, per node.

competition_id: str = PydanticUndefined
nodes: list[PrewarmNodeResult] = PydanticUndefined
class ProbeBrief(ctfy.core.models.CtfyModel):
559class ProbeBrief(CtfyModel):
560    """One AI pre-check in a line (#3093): the listing's AI column, a version
561    row's "AI 2/3", a history entry. The same `summarise_probes` reading as
562    the tab, without the runs — or, for an upload read by the pre-check runs
563    (#3445), `core.authored_assistant.brief_of_runs` over them.
564
565    ``solved`` / ``finished`` are the first question's — the one the verdict
566    reads — and ``finished`` never counts a failed or skipped run.
567    """
568
569    probe_id: str
570    revision_id: str = ""
571    created_at: AwareDatetime | None = None
572    verdict: ProbeVerdict = "unknown"
573    question_id: str = ""
574    solved: int = 0
575    finished: int = 0
576    #: Black-box runs that did not finish, in nobody's denominator.
577    unfinished: int = 0
578    #: #3445: what was counted. ``""`` is the stored black-box probe rows
579    #: (every upload probed before #3445: ``solved`` / ``finished`` are the
580    #: first question's); ``blind`` / ``reference`` are the version's
581    #: pre-check runs — the blind runs, else A and B — and count runs that
582    #: solved the whole challenge (``question_id`` is then ``""``).
583    basis: ProbeBriefBasis = ""

One AI pre-check in a line (#3093): the listing's AI column, a version row's "AI 2/3", a history entry. The same summarise_probes reading as the tab, without the runs — or, for an upload read by the pre-check runs (#3445), core.authored_assistant.brief_of_runs over them.

solved / finished are the first question's — the one the verdict reads — and finished never counts a failed or skipped run.

probe_id: str = PydanticUndefined
revision_id: str = ''
created_at: pydantic.types.AwareDatetime | None = None
verdict: Literal['easy', 'fairly_easy', 'fairly_hard', 'hard', 'unknown'] = 'unknown'
question_id: str = ''
solved: int = 0
finished: int = 0
unfinished: int = 0
basis: Literal['', 'blind', 'reference'] = ''
class ProbeModelChoice(ctfy.core.models.CtfyModel):
2887class ProbeModelChoice(CtfyModel):
2888    """One gateway model an organiser may choose for the AI pre-check (#3053):
2889    who runs it (``vendor``) is what choosing it agrees to."""
2890
2891    id: str
2892    display_name: str = ""
2893    vendor: str = ""
2894    provider_model: str = ""

One gateway model an organiser may choose for the AI pre-check (#3053): who runs it (vendor) is what choosing it agrees to.

id: str = PydanticUndefined
display_name: str = ''
vendor: str = ''
provider_model: str = ''
class ProbeQuestionTally(ctfy.core.models.CtfyModel):
500class ProbeQuestionTally(CtfyModel):
501    """How many models that finished solved one question (#3053, design §8).
502
503    ``finished`` never counts a failed or skipped run."""
504
505    question_id: str
506    solved: int = 0
507    finished: int = 0

How many models that finished solved one question (#3053, design §8).

finished never counts a failed or skipped run.

question_id: str = PydanticUndefined
solved: int = 0
finished: int = 0
class ProbeRun(ctfy.core.models.CtfyModel):
463class ProbeRun(CtfyModel):
464    """One run of the AI pre-check on one version (#2830, #3053): a black-box
465    model, or the white-box pass.
466
467    ⚠️ ``transcript``, ``findings`` and the quotes in them are the model's and
468    the target's text — hostile on both counts — rendered as plain text only.
469    ``solvable`` is three-valued and ``unknown`` claims nothing; a ``failed``
470    or ``skipped`` run carries its ``reason`` and is never "unsolved".
471
472    ⚠️ **Read by organisers and reviewers, and by the upload's own authors
473    only where the event shows it to them** (``author_reads_model_review``).
474    No board, catalog, suite or public route carries it
475    (`test_the_ai_precheck_has_no_public_reader.py`).
476    """
477
478    id: str
479    pass_kind: AssistantPass = "blackbox"
480    #: The registry row the organiser chose; ``model`` is what was asked.
481    model_id: str = ""
482    model: str = ""
483    outcome: AssistantOutcome = "skipped"
484    reason: AssistantReason = ""
485    solvable: AssistantSolvable = "unknown"
486    stop: AssistantStop = ""
487    questions: list[AssistantQuestionItem] = Field(default_factory=list)
488    transcript: list[AssistantTranscriptItem] = Field(default_factory=list)
489    findings: list[AssistantFindingItem] = Field(default_factory=list)
490    #: Findings dropped because no file and line located them.
491    ungrounded: int = 0
492    turns: int = 0
493    tool_calls: int = 0
494    input_tokens: int = 0
495    output_tokens: int = 0
496    seconds: int = 0
497    created_at: AwareDatetime | None = None

One run of the AI pre-check on one version (#2830, #3053): a black-box model, or the white-box pass.

⚠️ transcript, findings and the quotes in them are the model's and the target's text — hostile on both counts — rendered as plain text only. solvable is three-valued and unknown claims nothing; a failed or skipped run carries its reason and is never "unsolved".

⚠️ Read by organisers and reviewers, and by the upload's own authors only where the event shows it to them (author_reads_model_review). No board, catalog, suite or public route carries it (test_the_ai_precheck_has_no_public_reader.py).

id: str = PydanticUndefined
pass_kind: Literal['blackbox', 'whitebox'] = 'blackbox'
model_id: str = ''
model: str = ''
outcome: Literal['ran', 'skipped', 'failed'] = 'skipped'
reason: Literal['', 'over_budget', 'unreadable', 'instance_not_ready', 'not_isolated', 'no_gateway', 'no_model', 'no_relay', 'node_silent', 'unreachable', 'failed', 'run_failed'] = ''
solvable: Literal['solved', 'not_solved', 'unknown'] = 'unknown'
stop: Literal['', 'final', 'max_turns', 'halted', 'cut_off', 'refused'] = ''
questions: list[ctfy.core.state.models.AssistantQuestionItem] = PydanticUndefined
transcript: list[ctfy.core.state.models.AssistantTranscriptItem] = PydanticUndefined
findings: list[ctfy.core.state.models.AssistantFindingItem] = PydanticUndefined
ungrounded: int = 0
turns: int = 0
tool_calls: int = 0
input_tokens: int = 0
output_tokens: int = 0
seconds: int = 0
created_at: pydantic.types.AwareDatetime | None = None
class ProbeSmoke(ctfy.core.models.CtfyModel):
510class ProbeSmoke(CtfyModel):
511    """The newest smoke run of one probed version (#3156): whether the
512    author's own reference exploit captured on a fresh instance.
513
514    ⚠️ ``verdict == ""`` is *no smoke result for these bytes yet* — the
515    default, and it claims nothing (failure shape 5): the pre-check's first
516    row then says so, and never "it can be solved". ``failed_step`` is the
517    first step that failed or timed out (`failing_step`), ``""`` when the log
518    named none (a pass, an older node, or the log was pruned)."""
519
520    verdict: AuthoredSmokeState = ""
521    at: AwareDatetime | None = None
522    failed_step: SmokeStepKind | Literal[""] = ""
523    #: The question a failed ``grade`` step was about, ``""`` otherwise.
524    failed_question: str = ""

The newest smoke run of one probed version (#3156): whether the author's own reference exploit captured on a fresh instance.

⚠️ verdict == "" is no smoke result for these bytes yet — the default, and it claims nothing (failure shape 5): the pre-check's first row then says so, and never "it can be solved". failed_step is the first step that failed or timed out (failing_step), "" when the log named none (a pass, an older node, or the log was pruned).

verdict: Union[Literal[''], Literal['solved', 'unsolved', 'estate_failed', 'malformed', 'errored']] = ''
at: pydantic.types.AwareDatetime | None = None
failed_step: Union[Literal['build', 'up', 'health', 'reach', 'exploit', 'grade'], Literal['']] = ''
failed_question: str = ''
class ProbeVersion(ctfy.core.models.CtfyModel):
527class ProbeVersion(CtfyModel):
528    """The newest AI pre-check of one version (#3053): the numbers, the one
529    word, every model's run in the organiser's order, and the white-box pass.
530    """
531
532    #: ``""`` for the upload's own tree; otherwise the revision probed.
533    revision_id: str = ""
534    subject_submitted_at: AwareDatetime | None = None
535    created_at: AwareDatetime | None = None
536    #: The fixed rule over the first question — ``unknown`` when nobody finished.
537    verdict: ProbeVerdict = "unknown"
538    tally: list[ProbeQuestionTally] = Field(default_factory=list)
539    #: Black-box runs that did not finish (failed or skipped), left out of
540    #: every denominator and listed with their reasons.
541    unfinished: int = 0
542    blackbox: list[ProbeRun] = Field(default_factory=list)
543    whitebox: ProbeRun | None = None
544    #: Earlier pre-checks of this same version (reruns), not shown.
545    earlier: int = 0
546    #: Which pre-check this is (#3093): `core.authored_assistant.probe_key`.
547    probe_id: str = ""
548    #: #3156: the reference exploit's newest smoke of these same bytes — the
549    #: report card's first row. The default reads as *no smoke yet*.
550    smoke: ProbeSmoke = Field(default_factory=ProbeSmoke)
551    #: #3189: whether these bytes carry the author's ``solution/WRITEUP.md``.
552    #: ⚠️ ``None`` claims nothing (an older server, failure shape 5): the
553    #: first row then adds no hint. ``False`` is a version stored before the
554    #: writeup was required — the row shows the exploit's result alone and
555    #: tells the author the next version needs one.
556    has_writeup: bool | None = None

The newest AI pre-check of one version (#3053): the numbers, the one word, every model's run in the organiser's order, and the white-box pass.

revision_id: str = ''
subject_submitted_at: pydantic.types.AwareDatetime | None = None
created_at: pydantic.types.AwareDatetime | None = None
verdict: Literal['easy', 'fairly_easy', 'fairly_hard', 'hard', 'unknown'] = 'unknown'
tally: list[ProbeQuestionTally] = PydanticUndefined
unfinished: int = 0
blackbox: list[ProbeRun] = PydanticUndefined
whitebox: ProbeRun | None = None
earlier: int = 0
probe_id: str = ''
smoke: ProbeSmoke = PydanticUndefined
has_writeup: bool | None = None
class ProfileAgent(ctfy.core.models.CtfyModel):
111class ProfileAgent(CtfyModel):
112    """One of a person's own agents, as their profile lists it (#2928).
113
114    ⚠️ **An allow-list over ``HarnessState``, never the row.** The registry
115    row carries the image reference (a registry path the author chose
116    privately), the approved and probed digests and the whole admission
117    record; a profile is a page strangers read, so only what the author
118    published travels: the name and the source link they opted into.
119    """
120
121    id: str
122    display_name: str = ""
123    #: The repository the author published; ``""`` is *not published*.
124    source_url: str = ""
125    #: Agent-track competition results, each at that event's own placement
126    #: and through its anonymity policy -- the same projection as the
127    #: person's own 战绩 rows (``ProfileResults``). Newest first.
128    results: list[SittingResult] = Field(default_factory=list)
129    #: 最好名次 over ``results`` only.
130    best_result: SittingResult | None = None
131    #: The best public-suite evaluation, or ``None`` when there is none.
132    best_eval: AgentEvalBest | None = None

One of a person's own agents, as their profile lists it (#2928).

⚠️ An allow-list over HarnessState, never the row. The registry row carries the image reference (a registry path the author chose privately), the approved and probed digests and the whole admission record; a profile is a page strangers read, so only what the author published travels: the name and the source link they opted into.

id: str = PydanticUndefined
display_name: str = ''
source_url: str = ''
results: list[SittingResult] = PydanticUndefined
best_result: SittingResult | None = None
best_eval: AgentEvalBest | None = None
class ProfileAgents(ctfy.core.models.CtfyModel):
135class ProfileAgents(CtfyModel):
136    """``GET /users/{user_id}/agents``: the profile's agents section (#2928).
137
138    Gated by the ``agents`` visibility key (absent = shown). A stranger is
139    sent ``items=[]`` with ``shown=False`` when it is off; the person and a
140    platform admin are sent the list either way, ``shown`` saying whether
141    anybody else sees it.
142    """
143
144    items: list[ProfileAgent] = Field(default_factory=list)
145    shown: bool = True

GET /users/{user_id}/agents: the profile's agents section (#2928).

Gated by the agents visibility key (absent = shown). A stranger is sent items=[] with shown=False when it is off; the person and a platform admin are sent the list either way, shown saying whether anybody else sees it.

items: list[ProfileAgent] = PydanticUndefined
shown: bool = True
class ProfilePatchRequest(ctfy.core.models.CtfyModel):
544class ProfilePatchRequest(CtfyModel):
545    """Body for ``PATCH /me/profile``. Every field is optional so the
546    UI can send only the diff; the route only writes the fields that
547    were present in the JSON. ``None`` means "clear this field"."""
548
549    # ``Field(default=...)`` with a non-None sentinel is awkward in
550    # pydantic; we instead declare each field as Optional + default-None
551    # and rely on the request's raw dict (via ``model_fields_set``) to
552    # know which keys were actually present.
553    display_name: str = Field(default="", max_length=120)
554
555    @field_validator("display_name")
556    @classmethod
557    def _display_name(cls, value: str) -> str:
558        value = value.strip()
559        if not value or any(ord(c) < 32 for c in value):
560            raise ValueError("Display name must be non-empty and contain no control characters")
561        if email_default_name(value):
562            raise ValueError("Choose a nickname rather than an email address")
563        return value
564
565    bio: str | None = Field(default=None, max_length=280)
566    country: str | None = None
567    website_url: WebUrl | None = Field(default=None, max_length=200)
568    timezone: str | None = Field(default=None, max_length=80)
569    # Values are *handles*, not URLs — ``{"github": "octocat"}``. The
570    # client owns href construction from a fixed per-provider template
571    # (``https://github.com/<handle>``), and the two providers that do
572    # accept a free-form value run it through ``normalizeUrl`` /
573    # ``mastodonUrl``, both of which only ever emit ``https://…``. So a
574    # scheme constraint here would be the wrong shape: it would reject
575    # every legitimate handle while defending against nothing. What was
576    # actually missing is a size bound.
577    social_links: dict[str, str] | None = None
578    #: The interface language the person just chose (`switchLanguage`), so
579    #: mail is written in it. ``None`` clears the choice, and mail falls
580    #: back to the browser's language, then to both languages.
581    language: MailLanguage | None = None
582
583    @field_validator("social_links")
584    @classmethod
585    def _bound_social_links(cls, v: dict[str, str] | None) -> dict[str, str] | None:
586        if v is None:
587            return None
588        if len(v) > 10:
589            raise ValueError("social_links may contain at most 10 entries")
590        if any(len(handle) > 200 for handle in v.values()):
591            raise ValueError("each social link must be at most 200 characters")
592        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".

display_name: str = ''
bio: str | None = None
country: str | None = None
website_url: Optional[Annotated[str, AfterValidator(func=<function _require_http_scheme at 0x74f512354e00>)]] = None
timezone: str | None = None
language: Optional[Literal['en', 'zh-CN']] = None
class ProfileResults(ctfy.core.models.CtfyModel):
68class ProfileResults(CtfyModel):
69    """``GET /users/{user_id}/results``: the profile's 战绩 section (L7).
70
71    Only published final results of public events, never an anonymous or a
72    disqualified sitting; each rank is the board's row as *this* viewer reads
73    the board (anonymity applied). The owner asking gets this copy too, which
74    is what "as others see it" means. Empty when
75    the person turned the section off; ``shown`` says which, so a client can
76    leave the section out rather than draw an empty card.
77    """
78
79    items: list[SittingResult] = Field(default_factory=list)
80    #: 最好名次 over ``items`` only -- the rows this viewer is shown.
81    best: SittingResult | None = None
82    shown: bool = True

GET /users/{user_id}/results: the profile's 战绩 section (L7).

Only published final results of public events, never an anonymous or a disqualified sitting; each rank is the board's row as this viewer reads the board (anonymity applied). The owner asking gets this copy too, which is what "as others see it" means. Empty when the person turned the section off; shown says which, so a client can leave the section out rather than draw an empty card.

items: list[SittingResult] = PydanticUndefined
best: SittingResult | None = None
shown: bool = True
class ProfileStats(ctfy.core.models.CtfyModel):
719class ProfileStats(CtfyModel):
720    """Aggregate per-team analytics surfaced on the public profile.
721
722    Every section is independently visibility-gated; non-owner viewers
723    see ``[]`` for a section the team has marked private. Owner and
724    admin always see the full payload."""
725
726    calendar: list[CalendarBucket] = Field(default_factory=list)
727    by_tag: list[TagStat] = Field(default_factory=list)
728    by_difficulty: list[DifficultyStat] = Field(default_factory=list)
729    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 PromotionCandidateRow(ctfy.core.models.CtfyModel):
784class PromotionCandidateRow(CtfyModel):
785    """One team in the promotion list, with the board's own rank and score."""
786
787    rank: int
788    team_id: str
789    team_name: str
790    score: int
791    member_count: int = 0
792    #: Shown only because it ties the cutoff's score below the line.
793    beyond_top_n: bool = False
794    #: Its score equals the cutoff's and that tie crosses the line.
795    tied_at_cutoff: bool = False
796    excluded: bool = False

One team in the promotion list, with the board's own rank and score.

rank: int = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = PydanticUndefined
score: int = PydanticUndefined
member_count: int = 0
beyond_top_n: bool = False
tied_at_cutoff: bool = False
excluded: bool = False
class PromotionRequest(ctfy.core.models.CtfyModel):
771class PromotionRequest(CtfyModel):
772    """Body for ``POST /admin/competitions/{id}/promotion`` (#2120): bring
773    the top ``top_n`` teams of ``source_competition_id``'s board into this
774    competition. ``dry_run`` is the preview; the confirm re-sends the same
775    body with ``dry_run`` false."""
776
777    source_competition_id: str
778    top_n: int = Field(ge=1, le=PROMOTION_TOP_N_MAX)
779    #: Teams the organiser took out of the list. Never backfilled.
780    exclude_team_ids: list[str] = Field(default_factory=list, max_length=PROMOTION_TOP_N_MAX)
781    dry_run: bool = False

Body for POST /admin/competitions/{id}/promotion (#2120): bring the top top_n teams of source_competition_id's board into this competition. dry_run is the preview; the confirm re-sends the same body with dry_run false.

source_competition_id: str = PydanticUndefined
top_n: int = PydanticUndefined
exclude_team_ids: list[str] = PydanticUndefined
dry_run: bool = False
class PromotionResponse(ctfy.core.models.CtfyModel):
799class PromotionResponse(CtfyModel):
800    source_competition_id: str
801    #: Whether the source has ended — the import itself waits for it.
802    source_final: bool
803    top_n: int
804    #: The tie rule applied, stated so the page can say it.
805    tie_rule: TieRule = TIE_RULE
806    candidates: list[PromotionCandidateRow] = Field(default_factory=list)
807    #: The selected teams' members, through the batch add's executor.
808    members: BatchAddMembersResponse = Field(default_factory=BatchAddMembersResponse)
809    dry_run: 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.

source_competition_id: str = PydanticUndefined
source_final: bool = PydanticUndefined
top_n: int = PydanticUndefined
tie_rule: Literal['include_all_tied'] = 'include_all_tied'
candidates: list[PromotionCandidateRow] = PydanticUndefined
members: BatchAddMembersResponse = PydanticUndefined
dry_run: bool = False
class ProvidersResponse(ctfy.core.models.CtfyModel):
69class ProvidersResponse(CtfyModel):
70    # The sign-in buttons: link-only providers are never here.
71    providers: list[OAuthProviderInfo] = Field(default_factory=list)
72    # What an account may bind -- the same table as ``providers``, read
73    # without the link-only filter (and with a link-only provider this host
74    # does not carry shown as ``available_at`` the host that does).
75    link_providers: list[OAuthProviderInfo] = Field(default_factory=list)
76    password_auth: PasswordAuthInfo = Field(default_factory=PasswordAuthInfo)
77    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 PublicEvalDomainRate(ctfy.core.models.CtfyModel):
173class PublicEvalDomainRate(CtfyModel):
174    """One task type's public runs over the last 30 days. ``solve_rate`` is
175    ``None`` below ``core.public_stats.MIN_SAMPLE`` runs (样本不足)."""
176
177    domain: ChallengeDomain
178    group: DomainGroup
179    runs: int = 0
180    solve_rate: float | None = None

One task type's public runs over the last 30 days. solve_rate is None below core.public_stats.MIN_SAMPLE runs (样本不足).

domain: Literal['vuln_ctf', 'enterprise', 'awd', 'security_qa', 'web_ops', 'cs_qa', 'agent_safety'] = PydanticUndefined
group: Literal['security', 'general'] = PydanticUndefined
runs: int = 0
solve_rate: float | None = None
class PublicEvalHour(ctfy.core.models.CtfyModel):
166class PublicEvalHour(CtfyModel):
167    """Runs finished in the clock hour starting at ``start_ts``."""
168
169    start_ts: float
170    runs: int = 0

Runs finished in the clock hour starting at start_ts.

start_ts: float = PydanticUndefined
runs: int = 0
class PublicEvalRun(ctfy.core.models.CtfyModel):
183class PublicEvalRun(CtfyModel):
184    """One recently finished public run. ⚠️ The model's name only -- never
185    the harness (its id names a user) and never who started it."""
186
187    model_name: str
188    challenge_id: str
189    challenge_name: str = ""
190    solved: bool = False
191    finished_at_ts: float = 0.0

One recently finished public run. ⚠️ The model's name only -- never the harness (its id names a user) and never who started it.

model_name: str = PydanticUndefined
challenge_id: str = PydanticUndefined
challenge_name: str = ''
solved: bool = False
finished_at_ts: float = 0.0
class PublicEvalStats(ctfy.core.models.CtfyModel):
207class PublicEvalStats(CtfyModel):
208    """The home page's 托管测评 · 实时 section (#3413).
209
210    Totals (``running`` … ``total_tokens``, ``hourly``) count **every** run,
211    sandbox and hidden events included: a number, never whose. ``domains``,
212    ``recent`` and ``first_solves`` read **public** runs only -- ``official``
213    and ``self_reported`` tiers, in a competition a stranger may see that
214    is not scoring silently.
215    """
216
217    running: int = 0
218    queued: int = 0
219    #: How long the queued runs have waited so far, on average; ``None``
220    #: with nothing queued.
221    mean_wait_s: int | None = None
222    #: Where "today" begins (Beijing midnight).
223    day_starts_at_ts: float = 0.0
224    today_runs: int = 0
225    today_tokens: int = 0
226    #: ``None`` while the writer's daily rollup is behind: the page shows a
227    #: dash rather than a number read off the whole history per request.
228    total_runs: int | None = None
229    total_tokens: int | None = None
230    #: 24 bars, oldest first; the last is the hour now under way.
231    hourly: list[PublicEvalHour] = Field(default_factory=list)
232    domains: list[PublicEvalDomainRate] = Field(default_factory=list)
233    recent: list[PublicEvalRun] = Field(default_factory=list)
234    first_solves: list[PublicFirstSolve] = Field(default_factory=list)

The home page's 托管测评 · 实时 section (#3413).

Totals (running … total_tokens, hourly) count every run, sandbox and hidden events included: a number, never whose. domains, recent and first_solves read public runs only -- official and self_reported tiers, in a competition a stranger may see that is not scoring silently.

running: int = 0
queued: int = 0
mean_wait_s: int | None = None
day_starts_at_ts: float = 0.0
today_runs: int = 0
today_tokens: int = 0
total_runs: int | None = None
total_tokens: int | None = None
hourly: list[PublicEvalHour] = PydanticUndefined
domains: list[PublicEvalDomainRate] = PydanticUndefined
recent: list[PublicEvalRun] = PydanticUndefined
first_solves: list[PublicFirstSolve] = PydanticUndefined
class PublicFirstSolve(ctfy.core.models.CtfyModel):
194class PublicFirstSolve(CtfyModel):
195    """A challenge first fully solved by a public run in the last 24 hours.
196    ``attempts`` is how many public runs on it there had been, this one
197    included."""
198
199    challenge_id: str
200    challenge_name: str = ""
201    difficulty: Difficulty
202    model_name: str
203    attempts: int = 1
204    solved_at_ts: float = 0.0

A challenge first fully solved by a public run in the last 24 hours. attempts is how many public runs on it there had been, this one included.

challenge_id: str = PydanticUndefined
challenge_name: str = ''
difficulty: ctfy.challenge.benchmark.Difficulty = PydanticUndefined
model_name: str = PydanticUndefined
attempts: int = 1
solved_at_ts: float = 0.0
class PublicRecap(ctfy.core.models.CtfyModel):
333class PublicRecap(CtfyModel):
334    """What ``GET /competitions/{id}/recap`` publishes once an organiser
335    has published it: :class:`Recap` minus what is the organisers' own."""
336
337    phase: RecapPhase
338    generated_at: datetime
339    reach: PublicRecapReach
340    contest: RecapContest | None = None

What GET /competitions/{id}/recap publishes once an organiser has published it: Recap minus what is the organisers' own.

phase: Literal['registration', 'live', 'ended'] = PydanticUndefined
generated_at: datetime.datetime = PydanticUndefined
reach: ctfy.core.recap.PublicRecapReach = PydanticUndefined
contest: ctfy.core.recap.RecapContest | None = None
class PublicRecapReach(ctfy.core.models.CtfyModel):
318class PublicRecapReach(CtfyModel):
319    """:class:`RecapReach` minus the organisers' working figures."""
320
321    totals: RecapReachTotals
322    active_teams: int | None = None
323    schools: list[RecapCount] = Field(default_factory=list)
324    other_school_teams: int = 0
325    unaffiliated_teams: int = 0
326    regions: list[RecapCount] = Field(default_factory=list)
327    region_not_stated_teams: int = 0
328    tiers: list[RecapTierCount] = Field(default_factory=list)
329    daily: list[RecapDay] = Field(default_factory=list)
330    previous: RecapComparison | None = None

RecapReach minus the organisers' working figures.

totals: ctfy.core.recap.RecapReachTotals = PydanticUndefined
active_teams: int | None = None
schools: list[ctfy.core.recap.RecapCount] = PydanticUndefined
other_school_teams: int = 0
unaffiliated_teams: int = 0
regions: list[ctfy.core.recap.RecapCount] = PydanticUndefined
region_not_stated_teams: int = 0
tiers: list[ctfy.core.recap.RecapTierCount] = PydanticUndefined
daily: list[ctfy.core.recap.RecapDay] = PydanticUndefined
previous: ctfy.core.recap.RecapComparison | None = None
class PublicRoundedCount(ctfy.core.models.CtfyModel):
145class PublicRoundedCount(CtfyModel):
146    """A platform total floored to its leading digit (#3413): ``floor`` and
147    whether the true count is above it (``5,000+``). The exact number never
148    leaves the server (``core.public_stats.round_scale``)."""
149
150    floor: int = 0
151    more: bool = False

A platform total floored to its leading digit (#3413): floor and whether the true count is above it (5,000+). The exact number never leaves the server (core.public_stats.round_scale).

floor: int = 0
more: bool = False
class PublicScale(ctfy.core.models.CtfyModel):
154class PublicScale(CtfyModel):
155    """The hero's platform scale, rounded. ⚠️ No 30-day availability: the
156    platform holds no such number (the status page keeps per-component
157    90-day uptime), and the page shows nothing rather than a constant."""
158
159    #: Human accounts.
160    users: PublicRoundedCount
161    #: Events (not suites) that have started, counted as a stranger may see
162    #: them -- a ``private_hidden`` or draft event is never counted.
163    competitions: PublicRoundedCount

The hero's platform scale, rounded. ⚠️ No 30-day availability: the platform holds no such number (the status page keeps per-component 90-day uptime), and the page shows nothing rather than a constant.

users: PublicRoundedCount = PydanticUndefined
competitions: PublicRoundedCount = PydanticUndefined
class PublicStatsResponse(ctfy.core.models.CtfyModel):
237class PublicStatsResponse(CtfyModel):
238    """``GET /stats/public`` -- the same for every caller (#3413)."""
239
240    generated_at_ts: float
241    scale: PublicScale
242    evals: PublicEvalStats

GET /stats/public -- the same for every caller (#3413).

generated_at_ts: float = PydanticUndefined
scale: PublicScale = PydanticUndefined
evals: PublicEvalStats = PydanticUndefined
class PublicStatusComponent(ctfy.core.models.CtfyModel):
496class PublicStatusComponent(CtfyModel):
497    """One component as the public status endpoint reports it (#2921).
498
499    ⚠️ **State and timing only.** No hostname, node or instance count and
500    no capacity -- why ``/health`` could not be published instead."""
501
502    id: PushedComponent
503    state: ComponentState
504    #: Median seconds a challenge environment took to start over the last
505    #: ten minutes (``env_start`` only); ``None`` when nothing started.
506    latency_s: float | None = None

One component as the public status endpoint reports it (#2921).

⚠️ State and timing only. No hostname, node or instance count and no capacity -- why /health could not be published instead.

id: Literal['env_start', 'llm_gateway', 'email', 'eval_queue', 'flag_submission', 'attachments', 'vpn', 'challenge_nodes', 'jobs', 'website_api'] = PydanticUndefined
state: Literal['operational', 'degraded_performance', 'partial_outage', 'major_outage', 'unknown'] = PydanticUndefined
latency_s: float | None = None
class PublicStatusResponse(ctfy.core.models.CtfyModel):
509class PublicStatusResponse(CtfyModel):
510    """``GET /api/v1/status`` -- unauthenticated, CORS-open (#2921)."""
511
512    #: When the platform last read these (UTC); ``None`` before the first
513    #: reading, when every component is ``unknown``.
514    checked_at: AwareDatetime | None = None
515    components: list[PublicStatusComponent] = Field(default_factory=list)

GET /api/v1/status -- unauthenticated, CORS-open (#2921).

checked_at: pydantic.types.AwareDatetime | None = None
components: list[PublicStatusComponent] = PydanticUndefined
class PublicTranscriptBlock(ctfy.core.models.CtfyModel):
2661class PublicTranscriptBlock(CtfyModel):
2662    """One block of an agent's own turn, scrubbed (B2).
2663
2664    ⚠️ **Only three kinds ever reach the wire** — ``text`` (assistant
2665    prose), ``thinking`` (its reasoning), ``tool_use`` (a call it made).
2666    The server's whitelist (``core.agent_transcript``) never emits a
2667    system prompt, a tool *result* or anything else, so a fourth kind
2668    here would be a bug, not a new carrier. ``name`` is the tool for
2669    ``tool_use`` and empty otherwise; ``text`` is the prose, or the
2670    scrubbed ``key=value`` arguments for ``tool_use``.
2671    """
2672
2673    kind: str = ""
2674    text: str = ""
2675    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):
2684class PublicTranscriptResponse(CtfyModel):
2685    """An entered agent's public, scrubbed conversation (B2).
2686
2687    ⚠️ **The agent's own turns only.** ``had_system`` / ``had_tool_result``
2688    say a system prompt / a tool result was present and **withheld**, so
2689    the reader sees *that* content was removed rather than a gap that
2690    reads as the agent having said nothing. The bodies themselves never
2691    travel — see ``core.agent_transcript``'s whitelist.
2692    """
2693
2694    call_id: str = ""
2695    team_id: str = ""
2696    team_name: str = ""
2697    model: str = ""
2698    at_ts: float = 0.0
2699    #: A body to read at all — retention may have reclaimed it.
2700    available: bool = False
2701    had_system: bool = False
2702    had_tool_result: bool = False
2703    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):
2678class PublicTranscriptTurn(CtfyModel):
2679    """One assistant turn — a run of blocks the agent produced together."""
2680
2681    blocks: list[PublicTranscriptBlock] = Field(default_factory=list)

One assistant turn — a run of blocks the agent produced together.

blocks: list[PublicTranscriptBlock] = PydanticUndefined
class PushSubscriptionCreate(ctfy.core.models.CtfyModel):
83class PushSubscriptionCreate(CtfyModel):
84    """``POST /me/push-subscriptions``: this device's subscription, as the
85    browser's ``PushSubscription.toJSON()`` spells it (the extra
86    ``expirationTime`` is accepted and ignored)."""
87
88    #: The push service URL the platform will POST to. Refused unless it is
89    #: a known browser push service (``core/web_push.push_endpoint_allowed``).
90    endpoint: str = Field(min_length=1, max_length=2048)
91    keys: PushSubscriptionKeys

POST /me/push-subscriptions: this device's subscription, as the browser's PushSubscription.toJSON() spells it (the extra expirationTime is accepted and ignored).

endpoint: str = PydanticUndefined
keys: PushSubscriptionKeys = PydanticUndefined
class PushSubscriptionInfo(ctfy.core.models.CtfyModel):
 94class PushSubscriptionInfo(CtfyModel):
 95    """One device of the caller's. Never its endpoint or keys: the id (the
 96    endpoint's digest) is how the browser recognises its own row."""
 97
 98    id: str
 99    created_at: AwareDatetime | None = None
100    #: When a push service last accepted a message for it; ``None`` never.
101    last_used_at: AwareDatetime | None = None

One device of the caller's. Never its endpoint or keys: the id (the endpoint's digest) is how the browser recognises its own row.

id: str = PydanticUndefined
created_at: pydantic.types.AwareDatetime | None = None
last_used_at: pydantic.types.AwareDatetime | None = None
class PushSubscriptionKeys(ctfy.core.models.CtfyModel):
76class PushSubscriptionKeys(CtfyModel):
77    """The two keys a browser's ``PushSubscription.toJSON()`` carries."""
78
79    p256dh: str = Field(min_length=1, max_length=200)
80    auth: str = Field(min_length=1, max_length=100)

The two keys a browser's PushSubscription.toJSON() carries.

p256dh: str = PydanticUndefined
auth: str = PydanticUndefined
class PushSubscriptionsResponse(ctfy.core.models.CtfyModel):
104class PushSubscriptionsResponse(CtfyModel):
105    """What a device needs to subscribe, and which devices already are."""
106
107    #: The deployment's VAPID key, the browser's ``applicationServerKey``.
108    #: ``""`` when push is off on this deployment.
109    public_key: str
110    subscriptions: list[PushSubscriptionInfo]

What a device needs to subscribe, and which devices already are.

public_key: str = PydanticUndefined
subscriptions: list[PushSubscriptionInfo] = PydanticUndefined
class QaChallengeView(ctfy.core.models.CtfyModel):
1071class QaChallengeView(CtfyModel):
1072    """One QA challenge as seen by a team on the quiz surface.
1073
1074    Carries the full question / choices / description data alongside
1075    per-team solve and attempt state, so the frontend can render the
1076    quiz list with progress badges in a single round-trip.
1077    """
1078
1079    id: str
1080    name: str
1081    # Provenance bucket prefix — ``SECQA`` / ``MMLU-CS`` / ``CTI-MCQ``
1082    # / etc — derived from the challenge id (everything before the
1083    # trailing numeric suffix). Lets the frontend group / filter
1084    # without splitting the id again client-side.
1085    bucket: str
1086    description: str
1087    difficulty: str = ""
1088    tags: list[str] = Field(default_factory=list)
1089    questions: list[QuestionPublicInfo] = Field(default_factory=list)
1090    # Question ids the calling team has already captured.
1091    solved_question_ids: list[str] = Field(default_factory=list)
1092    # Question ids the team has submitted at least one wrong answer
1093    # against but never solved. Drives the "Try again" amber badge.
1094    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):
1054class QaSubmissionCreate(CtfyModel):
1055    """Request body for ``POST /qa/submissions`` — the instance-free
1056    submission path for pure question-answer challenges.
1057
1058    No ``instance_id``: QA challenges have no Docker infra to launch,
1059    so there's no per-team instance row to read context off. The
1060    caller passes ``challenge_id`` + ``competition_id`` explicitly,
1061    and the server resolves the submitter's team for the named
1062    competition via the standard auth helper.
1063    """
1064
1065    challenge_id: str
1066    competition_id: str
1067    question_id: str = "answer"
1068    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):
957class QuestionAttemptResetInfo(CtfyModel):
958    """Response from the admin reset endpoint. Echoes the new baseline
959    row so the UI can update its local state without re-fetching the
960    instance.
961    """
962
963    team_id: str
964    challenge_id: str
965    question_id: str
966    reset_at: datetime
967    reset_by_user_id: str
968    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):
635class QuestionAttemptResetRequest(CtfyModel):
636    """Request body for ``POST /admin/teams/.../reset-attempts``.
637
638    ``reason`` is free-form audit metadata recorded onto the activity
639    log row; capped at 500 chars so it round-trips through the SSE
640    payload without bloating the wire frame.
641    """
642
643    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):
2749class QuestionCoverageRow(CtfyModel):
2750    """Where one declared question ended one run.
2751
2752    ⚠️ The prompt travels because the reader is deciding what to do next,
2753    and ``dcsync_domain_admin`` is a slug. A table of slugs is a table
2754    nobody triages — ``AdminHarnessRow``'s finding, one queue over.
2755    """
2756
2757    question_id: str = ""
2758    prompt: str = ""
2759    #: ⚠️ **Required, no default.** The three members are three different
2760    #: repairs, so a fourth value meaning *this server did not say* would
2761    #: be a state with no reader and no remedy — ``AdminTimeseries.metric``'s
2762    #: rule. One producer writes this and it always knows.
2763    state: CoverageState
2764    #: What the Reporting grade said about this question — present only
2765    #: where this run captured it and the report has been graded.
2766    #:
2767    #: ⚠️ **A second axis, never a fourth ``CoverageState``.** Coverage
2768    #: says how much of the range was reached and this says whether the
2769    #: document admits it; they move independently, which is the whole
2770    #: premise of ``#1582``'s two numbers. Folding *found and never
2771    #: described* into the coverage enum rebuilds the one blended figure
2772    #: that made the hundred unreadable.
2773    #:
2774    #: ⚠️ **``None`` carries three absences and that is deliberate**: the
2775    #: run did not capture this, the report was never graded, or the
2776    #: stored verdict is a string this server does not recognise. The
2777    #: first is readable from ``state``; the second from ``graded`` on
2778    #: the envelope; the third is a platform fault that must not be
2779    #: invented into a verdict either way.
2780    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):
34class QuestionPublicInfo(CtfyModel):
35    """One question, as exposed to agents and the web UI.
36
37    Carries the prompt + mode + ``choices:`` enum (when present) so the
38    UI can render a radio/checkbox/free-text input without an extra
39    round-trip to fetch the spec. ``answer`` is NEVER projected — the
40    groundtruth lives on the platform either as metadata.yaml or as
41    a per-instance mint.
42    """
43
44    id: str
45    prompt: str
46    # Grading mode: ``dynamic`` | ``static`` | ``single_select`` |
47    # ``multi_select``. ``None`` only on malformed metadata (which the
48    # audit catches before deploy).
49    mode: str | None = None
50    # Closed-list options for ``single_select`` / ``multi_select``;
51    # ``None`` for free-form modes.
52    choices: list[str] | None = None
53    # Question ids that must be answered correctly first. Both public
54    # surfaces gate on this list: the *instance* projection
55    # (``instances._to_info``) redacts a locked prompt, and the *catalog*
56    # projection (``projections.question_infos``) omits the entry
57    # entirely until it unlocks.
58    # ⚠️ This comment used to claim that gating while the catalog
59    # published every gated prompt, id, ``requires`` edge and the total
60    # count — a false statement about disclosure, written on the model
61    # that was doing the disclosing. Recorded rather than quietly edited
62    # because it read as a guarantee and was believed.
63    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):
214class RangeProgress(CtfyModel):
215    """Every team's position on one range of one competition."""
216
217    competition_id: str = ""
218    challenge_id: str = ""
219    rows: list[RangeProgressRow] = Field(default_factory=list)
220    #: How many milestones the range declares — the denominator for
221    #: ``reached``. ⚠️ ``0`` means the catalog could not resolve the
222    #: challenge, never "no milestones": a reader must render no fraction
223    #: at all rather than dividing by it, the rule
224    #: ``AdminSolveMatrixChallenge.milestones`` already records.
225    milestone_count: int = 0
226    #: ⚠️ ``False`` when the observation read hit its cap. Every tier is
227    #: *positive* evidence, so a truncated read can only make hosts look
228    #: less reached than they were — the direction nobody reports, hence
229    #: a published flag rather than a silent bound.
230    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):
147class RangeProgressRow(CtfyModel):
148    """Where one team is on one range, for the people running it.
149
150    The sibling :class:`ChallengeBlueprint` says what the range *is*;
151    this says where a team got to on it, and the two are fetched
152    separately on purpose — the blueprint is a property of the challenge
153    and cacheable, this changes on every save.
154
155    ⚠️ **No network depth here.** "Which layer" wants a BFS from the
156    published segment and that walk has one home already
157    (``frontend/src/lib/segmentDepth.ts``); the client derives the layer
158    from ``compromised`` against the blueprint it is already holding.
159    ``per_segment`` is the raw material for that and needs no ordering
160    of the segments to be true.
161
162    ⚠️ **No groundtruth**, exactly as on the blueprint: this names which
163    milestone *ids* a team holds, never what any of them is worth or
164    what the minted value was.
165    """
166
167    team_id: str = ""
168    team_name: str = ""
169    #: See :attr:`ScoreboardEntry.anonymous`. An organiser of this
170    #: competition sees through it; anybody else reads the alias.
171    anonymous: bool = False
172    #: ⚠️ Cleared for a withheld row by the same projection that clears
173    #: the name — ``anonymity._board_fields`` argues it: a surface that
174    #: withheld the name and kept the face would be decorative.
175    avatar_url: str = ""
176    reached: list[str] = Field(default_factory=list)
177    #: Reached milestones with at least one unreached successor — where
178    #: this team is stopped. ⚠️ Empty is *both* "nothing reached" and
179    #: "every reached branch is finished"; ``reached`` is what separates
180    #: them, which is why a reader must not key on this alone.
181    frontier: list[str] = Field(default_factory=list)
182    #: Unreached milestones whose prerequisites are all satisfied — what
183    #: this team could take next, including the origins of paths they
184    #: have never touched.
185    available: list[str] = Field(default_factory=list)
186    #: Estate hosts carrying a reached milestone. **Observed.**
187    compromised: list[str] = Field(default_factory=list)
188    #: Estate hosts sharing a segment with a compromised one.
189    #: ⚠️ **Inferred, not observed** — the platform does not know where a
190    #: team's shell is. A renderer that paints this like ``compromised``
191    #: is asserting a measurement nobody took.
192    reachable: list[str] = Field(default_factory=list)
193    #: ``segment -> [reached, total]`` over milestones carried there.
194    per_segment: dict[str, list[int]] = Field(default_factory=dict)
195    #: Reached milestones **no estate segment carries** — external recon
196    #: and OSINT. ⚠️ Published because it cannot be derived from the two
197    #: fields beside it: ``reached`` is a set and ``per_segment`` is a
198    #: multiset over ``(milestone, segment)`` pairs, so the subtraction
199    #: that suggests itself type-checks and means nothing —
200    #: ``RangeProgress.off_segment`` carries the measurement.
201    off_segment: list[str] = Field(default_factory=list)
202    #: ``host -> EvidenceTier`` — how strongly this team is known to have
203    #: reached each host of the estate. ⚠️ **One mark per host, never a
204    #: stack**: the tiers are strengths, not stages a team walked
205    #: through, and `ctfy.core.evidence` argues why a cumulative
206    #: rendering asserts a history nobody recorded.
207    #:
208    #: ⚠️ An empty map means the blueprint could not be read, never
209    #: "nothing was observed" — every host of a readable estate has an
210    #: entry, including the ones at ``none``.
211    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):
278class RangeReplay(CtfyModel):
279    """One range wound back, as a fixed number of frames.
280
281    ⚠️ **Computed server-side and shipped once**, which is a decision
282    with a named alternative: a client that tallied ``per_segment``
283    itself at time T would be a second implementation of the
284    milestone→segment map, and one round trip per slider step would put
285    a fetch on every drag. So the count of frames is the knob, and the
286    payload is bounded by it rather than by how long the event ran.
287
288    ⚠️ **The clock is ``solved_at``**, so the last frame equals the
289    static ``/progress`` picture element for element — the property the
290    feature rests on — and a capture taken before the window opened is
291    held in frame 0 rather than appearing mid-run.
292    ``ctfy.core.range_replay`` carries the argument.
293    """
294
295    competition_id: str = ""
296    challenge_id: str = ""
297    #: Ascending in time, both ends of the span included.
298    #:
299    #: ⚠️ **Empty means the range has no time axis** — nothing was
300    #: captured *and* the competition declares no window — never "the
301    #: replay is empty". A reader draws no scrubber at all rather than an
302    #: inert one; ``0`` frames and one frame of zeroes are different
303    #: sentences.
304    frames: list[RangeReplayFrame] = Field(default_factory=list)
305    #: See :attr:`RangeProgress.milestone_count` — ``0`` means the
306    #: catalog could not resolve the challenge, so a reader renders no
307    #: fraction rather than dividing by it.
308    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):
267class RangeReplayFrame(CtfyModel):
268    """Every team's position at one instant."""
269
270    #: ⚠️ Required and never ``None``: a frame is *the* answer at an
271    #: instant, so a frame that cannot say when it is is not a frame.
272    #: The nullable case is the whole replay being absent, which
273    #: :attr:`RangeReplay.frames` says with an empty list.
274    at: AwareDatetime
275    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):
233class RangeReplayPosition(CtfyModel):
234    """One team's coordinate material at one instant of the replay.
235
236    ⚠️ **Narrower than :class:`RangeProgressRow`, and the two fields are
237    the two a *track* reads.** ``frontier`` / ``available`` /
238    ``compromised`` / ``reachable`` / ``evidence`` are what the estate
239    map draws for a single picked team; a replay carries every team at
240    every instant, so publishing all of it would multiply the payload by
241    the milestone count for readers that would drop it.
242
243    ⚠️ **No identity here at all — not even a name.** The page holding
244    this already fetched ``/progress``, whose rows carry the name, the
245    avatar and the anonymity verdict; a second copy travelling per frame
246    would be one more surface to remember when the projection changes,
247    which is exactly how ``anonymise_range_progress`` warns a new field
248    goes out unprojected. The join key is ``team_id``, and the replay
249    publishes a position only for a team ``/progress`` published a row
250    for — see ``admin_range_progress``.
251    """
252
253    team_id: str = ""
254    #: How many declared milestones this team held at this instant.
255    #: ⚠️ A count, never the id list: the ids are on the progress row
256    #: and do not change per frame in any way a track renders.
257    reached: int = 0
258    #: ``segment -> [reached, total]``, the same shape and the same
259    #: producer as :attr:`RangeProgressRow.per_segment`.
260    per_segment: dict[str, list[int]] = Field(default_factory=dict)
261    #: How many of those reached milestones no estate segment carries.
262    #: ⚠️ A count here and ids on the progress row, for `reached`'s own
263    #: reason: identity lives there, a frame publishes a position.
264    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):
2733class ReactionCount(CtfyModel):
2734    """How many people gave one reaction chip."""
2735
2736    reaction: Reaction
2737    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 ReadAllResult(ctfy.core.models.CtfyModel):
266class ReadAllResult(CtfyModel):
267    """How many threads (and in-app notifications, #2567) a bulk read or
268    archive touched."""
269
270    threads: int = 0
271    notifications: int = 0

How many threads (and in-app notifications, #2567) a bulk read or archive touched.

threads: int = 0
notifications: int = 0
class ReauthenticateRequest(ctfy.core.models.CtfyModel):
609class ReauthenticateRequest(CtfyModel):
610    """Body for ``POST /me/reauthenticate`` — prove a factor again, now.
611
612    Both fields default blank and the server decides which one it wants
613    (``account_deletion.required_proof``), so a client does not have to
614    guess and an account whose answer is ``none`` sends ``{}``. That is
615    the same shape :class:`DeleteMeRequest` takes, on purpose: two
616    request models disagreeing about what counts as proof is the
617    four-routes-four-answers problem this replaces, one layer down.
618    """
619
620    current_password: str = ""
621    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):
595class ReauthenticationOpened(CtfyModel):
596    """What ``POST /me/reauthenticate`` answers with.
597
598    ⚠️ **The window travels rather than being hardcoded**, for
599    ``MeResponse.deletion_proof``'s reason: the length is an operator
600    setting, so a client that assumed one would prompt for a proof the
601    server still considers fresh — or, worse, stop prompting while the
602    server has already closed the window, which surfaces as a refusal
603    the user cannot connect to anything they did.
604    """
605
606    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 Recap(ctfy.core.models.CtfyModel):
303class Recap(CtfyModel):
304    phase: RecapPhase
305    generated_at: datetime
306    #: First section after the KPI tiles; ``[]`` reads "nothing".
307    attention: list[RecapAttention] = Field(default_factory=list)
308    reach: RecapReach
309    #: ``None`` before the start, while the set is withheld, or when silent.
310    contest: RecapContest | None = None
311    integrity: RecapIntegrity
312    platform: RecapPlatform | 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.

phase: Literal['registration', 'live', 'ended'] = PydanticUndefined
generated_at: datetime.datetime = PydanticUndefined
attention: list[ctfy.core.recap.RecapAttention] = PydanticUndefined
reach: ctfy.core.recap.RecapReach = PydanticUndefined
contest: ctfy.core.recap.RecapContest | None = None
integrity: ctfy.core.recap.RecapIntegrity = PydanticUndefined
platform: ctfy.core.recap.RecapPlatform | None = None
class RecapDueInfo(ctfy.core.models.CtfyModel):
143class RecapDueInfo(CtfyModel):
144    """The report the clock has made due now, and whether it went out."""
145
146    kind: RecapReportKind
147    period_key: str
148    crossing: datetime
149    sent: bool = False

The report the clock has made due now, and whether it went out.

kind: Literal['weekly', 'eve', 'daily', 'final'] = PydanticUndefined
period_key: str = PydanticUndefined
crossing: datetime.datetime = PydanticUndefined
sent: bool = False
class RecapLinkReadRequest(ctfy.core.models.CtfyModel):
192class RecapLinkReadRequest(CtfyModel):
193    model_config = ConfigDict(extra="forbid")
194
195    token: str = Field(min_length=1, max_length=2000)

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.

token: str = PydanticUndefined
class RecapLinkView(ctfy.core.models.CtfyModel):
198class RecapLinkView(CtfyModel):
199    """A competition's recap as a report email's read-only link shows it."""
200
201    competition_id: str
202    title: str = ""
203    title_i18n: LocalizedText = Field(default_factory=dict)
204    kind: RecapReportKind
205    expires_at: datetime
206    recap: Recap

A competition's recap as a report email's read-only link shows it.

competition_id: str = PydanticUndefined
title: str = ''
title_i18n: dict[str, str] = PydanticUndefined
kind: Literal['weekly', 'eve', 'daily', 'final'] = PydanticUndefined
expires_at: datetime.datetime = PydanticUndefined
recap: ctfy.core.recap.Recap = PydanticUndefined
class RecapMailPreview(ctfy.core.models.CtfyModel):
183class RecapMailPreview(CtfyModel):
184    """What "send now" would mail, rendered: the organisers read it before
185    anybody else does."""
186
187    kind: RecapReportKind
188    subject: str
189    html: str

What "send now" would mail, rendered: the organisers read it before anybody else does.

kind: Literal['weekly', 'eve', 'daily', 'final'] = PydanticUndefined
subject: str = PydanticUndefined
html: str = PydanticUndefined
class RecapRecipientInfo(ctfy.core.models.CtfyModel):
115class RecapRecipientInfo(CtfyModel):
116    """One recipient as the organisers' page shows it: masked, never whole.
117
118    ``id`` is a digest of the address, stable across reads, and is what a
119    removal names -- the page never needs the plaintext back.
120    """
121
122    id: str
123    masked: str

One recipient as the organisers' page shows it: masked, never whole.

id is a digest of the address, stable across reads, and is what a removal names -- the page never needs the plaintext back.

id: str = PydanticUndefined
masked: str = PydanticUndefined
class RecapSendInfo(ctfy.core.models.CtfyModel):
126class RecapSendInfo(CtfyModel):
127    """One report that went out."""
128
129    id: str
130    period_key: str
131    kind: RecapReportKind
132    trigger: RecapSendTrigger
133    sent_at: datetime | None = None
134    #: The organiser who pressed "send now"; ``""`` for the schedule.
135    sent_by: str = ""
136    sent_by_name: str = ""
137    recipients: int = 0
138    link_expires_at: datetime | None = None
139    #: Whether this report's link still opens (unexpired, not revoked).
140    link_active: bool = False

One report that went out.

id: str = PydanticUndefined
period_key: str = PydanticUndefined
kind: Literal['weekly', 'eve', 'daily', 'final'] = PydanticUndefined
trigger: Literal['schedule', 'manual'] = PydanticUndefined
sent_at: datetime.datetime | None = None
sent_by: str = ''
sent_by_name: str = ''
recipients: 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):
 91class RecordAuthoredChallenge(CtfyModel):
 92    """One upload into this competition, and who wrote it."""
 93
 94    id: str
 95    title: str = ""
 96    team_id: str = ""
 97    uploader_user_id: str = ""
 98    author_user_ids: list[str] = Field(default_factory=list)
 99    review_status: ReviewStatus = "pending"
100    smoke_verdict: AuthoredSmokeState = ""
101    destination: AuthoredDestination = "public"
102    submitted_at: datetime | None = None
103    #: Whether the challenge is in this competition's set right now.
104    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 RecordChallengeChange(ctfy.core.models.CtfyModel):
107class RecordChallengeChange(CtfyModel):
108    """One recorded change to the competition's challenge set (#2041, #2553)
109    — an organiser's confirmed change to a locked event, or the platform
110    taking an upload out (``changed_by`` = the platform, ``reason`` its code)."""
111
112    id: str
113    challenge_id: str
114    action: str = "added"
115    reason: str = ""
116    changed_by: str = ""
117    changed_at: datetime | None = None

One recorded change to the competition's challenge set (#2041, #2553) — an organiser's confirmed change to a locked event, or the platform taking an upload out (changed_by = the platform, reason its code).

id: str = PydanticUndefined
challenge_id: str = PydanticUndefined
action: str = 'added'
reason: str = ''
changed_by: str = ''
changed_at: datetime.datetime | None = None
class RecordEvalRun(ctfy.core.models.CtfyModel):
62class RecordEvalRun(CtfyModel):
63    """One dispatched agent run (never a queued one)."""
64
65    id: str
66    team_id: str = ""
67    harness_id: str = ""
68    model_id: str = ""
69    challenge_id: str = ""
70    status: EvalRunStatus = "pending"
71    solved: list[str] = Field(default_factory=list)
72    fully_solved: bool = False
73    input_tokens: int = 0
74    output_tokens: int = 0
75    #: Calls the model gateway relayed, as opposed to what the agent
76    #: reported about itself (``usage_source``).
77    relayed_calls: int = 0
78    usage_source: str = "agent"
79    stop_reason: str = ""
80    error: str = ""
81    started_at_ts: float = 0.0
82    finished_at_ts: float = 0.0
83    #: The run that took this one's place (#2585: the platform's retry after
84    #: a platform error, or a person's requeue / rerun of a cancelled task);
85    #: ``""`` when nothing did. A superseded run really ran and is not a
86    #: sample — the board skips it. Runs never dispatched (still queued, or
87    #: cancelled before their turn) are not in this export.
88    superseded_by_run_id: str = ""

One dispatched agent run (never a queued one).

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', 'unservable'] = '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
superseded_by_run_id: str = ''
class RecordSolve(ctfy.core.models.CtfyModel):
36class RecordSolve(CtfyModel):
37    """One question answered correctly, as the board ranks it."""
38
39    team_id: str
40    user_id: str = ""
41    challenge_id: str
42    question_id: str = ""
43    solved_at: datetime | None = None
44    #: ``None`` when the solve does not count in this competition — an
45    #: author on their own upload, or a capture outside the window.
46    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):
49class RecordSubmission(CtfyModel):
50    """One submission. ⚠️ Never the submitted text: a correct one *is* a
51    minted answer, and this file leaves the platform."""
52
53    id: str
54    team_id: str
55    user_id: str = ""
56    challenge_id: str
57    question_id: str = ""
58    correct: bool = False
59    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):
16class RecordTeam(CtfyModel):
17    """One entrant: a human team, or an agent's synthetic team."""
18
19    id: str
20    name: str = ""
21    entrant_kind: EntrantKind = "human"
22    #: The members' user ids. A synthetic agent team holds only its
23    #: synthetic user.
24    member_user_ids: list[str] = Field(default_factory=list)
25    #: For an agent team: the harness, the person who registered it, and
26    #: that person's own team in this competition (``""`` when they hold
27    #: none). This is the join an event that credits an agent's score to
28    #: its owner's team needs, and the synthetic id alone cannot answer it.
29    agent_harness_id: str = ""
30    agent_owner_user_id: str = ""
31    agent_owner_team_id: str = ""
32    disqualified: bool = False
33    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):
124class RegisterRequest(CtfyModel):
125    email: EmailStr
126    # Length minimum mirrors ``MIN_PASSWORD_LENGTH`` in
127    # ``ctfy.server.password``. ``validate_password_strength`` runs the
128    # full check (length + common-password reject) inside the route
129    # handler so the rule lives in one place; this floor is a cheap
130    # client-side hint that catches obviously short input before it
131    # reaches argon2.
132    password: str = Field(min_length=12, max_length=256)
133    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 RegistrationCapacity(ctfy.core.models.CtfyModel):
957class RegistrationCapacity(CtfyModel):
958    """A competition's places as the reviewer and the player read them
959    (#2767 PR 6, ``core.registration_capacity``). Counts only, no person."""
960
961    #: ``0`` = no cap; the rest of the fields are then zero / ``None``.
962    capacity: int = 0
963    counts: CapacityCounts = "submitted"
964    at_capacity: AtCapacity = "stop"
965    #: Teams holding a place, counted by key over the whole competition.
966    held: int = 0
967    #: ``None`` = no cap (not disclosed is not zero).
968    remaining: int | None = None
969    full: bool = False
970    #: Teams queued on the waitlist.
971    waitlisted: int = 0

A competition's places as the reviewer and the player read them (#2767 PR 6, core.registration_capacity). Counts only, no person.

capacity: int = 0
counts: Literal['submitted', 'approved'] = 'submitted'
at_capacity: Literal['stop', 'waitlist'] = 'stop'
held: int = 0
remaining: int | None = None
full: bool = False
waitlisted: int = 0
class RegistrationCarsiEvidence(ctfy.core.models.CtfyModel):
333class RegistrationCarsiEvidence(CtfyModel):
334    """CARSI role evidence without provider identifiers or tokens."""
335
336    domains: list[str] = Field(default_factory=list)
337    roles: list[str] = Field(default_factory=list)
338    proved_at: datetime | None = None

CARSI role evidence without provider identifiers or tokens.

domains: list[str] = PydanticUndefined
roles: list[str] = PydanticUndefined
proved_at: datetime.datetime | None = None
class RegistrationConditionCounts(ctfy.core.models.CtfyModel):
1007class RegistrationConditionCounts(CtfyModel):
1008    """Teams per condition filter, counted over the whole scope (#2767 A8).
1009
1010    ``total`` is the teams in scope. A team counts in its verdict's bucket and,
1011    beside it, in ``invited`` and ``approved_not_eligible`` (#2932), so the
1012    buckets do not add up to ``total``.
1013    """
1014
1015    total: int = 0
1016    fully_eligible: int = 0
1017    missing_n: int = 0
1018    ineligible: int = 0
1019    invited: int = 0
1020    approved_not_eligible: int = 0

Teams per condition filter, counted over the whole scope (#2767 A8).

total is the teams in scope. A team counts in its verdict's bucket and, beside it, in invited and approved_not_eligible (#2932), so the buckets do not add up to total.

total: int = 0
fully_eligible: int = 0
missing_n: int = 0
ineligible: int = 0
invited: int = 0
approved_not_eligible: int = 0
class RegistrationConditionsPreview(ctfy.core.models.CtfyModel):
552class RegistrationConditionsPreview(CtfyModel):
553    """What a proposed set of conditions means for the teams already registered.
554
555    The organiser's pre-save preview on the 报名权限 card (#2767): every
556    team asked ``core.registration_conditions.team_verdict`` under the
557    *unsaved* settings. A flagged team is never removed -- the card says so.
558    ``teams`` is the first :data:`PREVIEW_TEAM_ROWS` flagged, hard verdicts
559    first; ``affected`` counts them all, so a long tail is never hidden.
560    """
561
562    competition_id: str
563    #: The blocks the proposed settings switch on, in matrix order.
564    conditions: list[ConditionId] = Field(default_factory=list)
565    on_ineligible: OnIneligible = "refuse"
566    #: Teams judged (every team not ruled out).
567    total: int = 0
568    #: Teams not ``fully_eligible`` under the proposed settings.
569    affected: int = 0
570    #: Of ``affected``, those ``fully_eligible`` under the saved settings.
571    newly_affected: int = 0
572    teams: list[ConditionsPreviewTeam] = Field(default_factory=list)

What a proposed set of conditions means for the teams already registered.

The organiser's pre-save preview on the 报名权限 card (#2767): every team asked core.registration_conditions.team_verdict under the unsaved settings. A flagged team is never removed -- the card says so. teams is the first PREVIEW_TEAM_ROWS flagged, hard verdicts first; affected counts them all, so a long tail is never hidden.

competition_id: str = PydanticUndefined
conditions: list[typing.Literal['proof', 'role', 'institution', 'same_institution', 'team_size', 'registration_complete']] = PydanticUndefined
on_ineligible: Literal['refuse', 'review'] = 'refuse'
total: int = 0
affected: int = 0
newly_affected: int = 0
teams: list[ConditionsPreviewTeam] = PydanticUndefined
class RegistrationEmailEvidence(ctfy.core.models.CtfyModel):
312class RegistrationEmailEvidence(CtfyModel):
313    """One email claim shown only to a registration reviewer."""
314
315    email: str
316    primary: bool = False
317    #: The address the competition's eligibility verdict qualifies the member
318    #: on (#2763) -- often *not* the primary. At most one per member; ``False``
319    #: on every address of an unrestricted competition.
320    qualifies: bool = False
321    verified: bool = False
322    verified_at: datetime | None = None
323    domain: str = ""
324    institution_domain: str = ""
325    institution_names: dict[str, str] = Field(default_factory=dict)
326    institution_short_name: str = ""
327    institution_roles: list[str] = Field(default_factory=list)
328    #: The address is verified and sits under ``.edu.cn`` (#2747): a fact the
329    #: reviewer reads beside an uncatalogued school, never an institution.
330    education: bool = False

One email claim shown only to a registration reviewer.

email: str = PydanticUndefined
primary: bool = False
qualifies: bool = False
verified: bool = False
verified_at: datetime.datetime | None = None
domain: str = ''
institution_domain: str = ''
institution_names: dict[str, str] = PydanticUndefined
institution_short_name: str = ''
institution_roles: list[str] = PydanticUndefined
education: bool = False
class RegistrationFieldSpec(ctfy.core.models.CtfyModel):
 92class RegistrationFieldSpec(CtfyModel):
 93    """One field the competition's form should render.
 94
 95    The server drives the form rather than the frontend hard-coding it,
 96    so an organiser flipping a field from optional to required takes
 97    effect without a redeploy — and so the client cannot disagree with
 98    the server about what is mandatory.
 99    """
100
101    name: str
102    policy: RegistrationFieldPolicy
103    # True for the fields the captain answers once for the whole squad
104    # (team logo, team honours) rather than each member answering.
105    team_scoped: bool = False
106    # True for 个人敏感信息 — the frontend renders the consent notice and
107    # the "organisers only" hint against exactly these.
108    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):
111class RegistrationFormSpec(CtfyModel):
112    """Everything the client needs to render + gate the wizard."""
113
114    competition_id: str
115    fields: list[RegistrationFieldSpec] = Field(default_factory=list)
116    registration_starts_at: datetime | None = None
117    registration_ends_at: datetime | None = None
118    # "not_open" | "open" | "closed" — the client shows a countdown, the
119    # form, or a dead end respectively.
120    phase: Literal["not_open", "open", "closed"] = "open"
121    max_team_size: int = 0
122    review_required: bool = False
123    #: Whether only an approved team may play here (#2223) -- review *and*
124    #: ``play_requires_approval``, composed by ``review_gates_play``.
125    review_gates_play: bool = False
126    collects_sensitive_pii: bool = False
127    #: Whether this competition offers to withhold an entrant's identity,
128    #: and what withholding looks like. The wizard renders the control
129    #: only when the policy is not ``forbidden``, and renders it as a
130    #: statement rather than a choice when it is ``required``.
131    #:
132    #: ⚠️ Server-driven like every other field on this spec, and for the
133    #: same reason: a client that decided for itself whether to offer the
134    #: control would be a second copy of the organiser's policy, and the
135    #: copy that is wrong is the one that offers a promise the server
136    #: will not keep.
137    anonymous_entry: AnonymityPolicy = "forbidden"
138    anonymous_entry_kind: AnonymityKind = "pseudonymous"
139    #: Words that make an ``academic_stage`` answer read as a university one
140    #: (#2293), so the form can show the school-identity prompt as the entrant
141    #: types without holding a second copy of the list.
142    university_stage_markers: list[str] = Field(default_factory=list)

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
review_gates_play: bool = False
collects_sensitive_pii: bool = False
anonymous_entry: Literal['forbidden', 'optional', 'required'] = 'forbidden'
anonymous_entry_kind: Literal['pseudonymous', 'hidden'] = 'pseudonymous'
university_stage_markers: list[str] = PydanticUndefined
class RegistrationIdentityEvidence(ctfy.core.models.CtfyModel):
360class RegistrationIdentityEvidence(CtfyModel):
361    """Reviewer-only identity evidence for one competition team."""
362
363    competition_id: str
364    team_id: str
365    members: list[RegistrationMemberIdentityEvidence] = Field(default_factory=list)

Reviewer-only identity evidence for one competition team.

competition_id: str = PydanticUndefined
team_id: str = PydanticUndefined
members: list[RegistrationMemberIdentityEvidence] = PydanticUndefined
class RegistrationMatch(ctfy.core.models.CtfyModel):
402class RegistrationMatch(CtfyModel):
403    """Why a team is on a searched roster page (#2518).
404
405    One entry per member and field that contained the keyword, so a
406    volunteer answering a ticket sees "member 陈一一's real name" rather
407    than a team that is merely there. ``value`` is the text that matched.
408    Only present when the request carried ``q``.
409    """
410
411    field: RegistrationMatchField
412    user_id: str = ""
413    value: str = ""

Why a team is on a searched roster page (#2518).

One entry per member and field that contained the keyword, so a volunteer answering a ticket sees "member 陈一一's real name" rather than a team that is merely there. value is the text that matched. Only present when the request carried q.

field: Literal['team_name', 'user_id', 'display_name', 'email', 'real_name'] = PydanticUndefined
user_id: str = ''
value: str = ''
class RegistrationMemberIdentityEvidence(ctfy.core.models.CtfyModel):
341class RegistrationMemberIdentityEvidence(CtfyModel):
342    """All identity evidence for one team member."""
343
344    user_id: str
345    display_name: str = ""
346    emails: list[RegistrationEmailEvidence] = Field(default_factory=list)
347    carsi: list[RegistrationCarsiEvidence] = Field(default_factory=list)
348    #: The competition's verdict on this member, from every address and CARSI
349    #: binding they hold (#2763) -- the producer the door and the player's card
350    #: ask. ``open`` on an unrestricted competition, never ``eligible``.
351    eligibility: EligibilityState = "open"
352    #: Which proof qualified (``email`` / ``carsi``), ``""`` unless ``eligible``.
353    eligibility_via: EligibilityVia = ""
354    #: Some evidence the catalogue cannot classify, *and* the verdict does not
355    #: already qualify the member. Never raised by a non-school primary beside a
356    #: qualifying school address.
357    manual_review: bool = False

All identity evidence for one team member.

user_id: str = PydanticUndefined
display_name: str = ''
emails: list[RegistrationEmailEvidence] = PydanticUndefined
carsi: list[RegistrationCarsiEvidence] = PydanticUndefined
eligibility: Literal['open', 'eligible', 'needs_email', 'needs_verification', 'needs_review'] = 'open'
eligibility_via: Literal['', 'email', 'carsi'] = ''
manual_review: bool = False
class RegistrationMessageTemplate(ctfy.core.models.CtfyModel):
1202class RegistrationMessageTemplate(CtfyModel):
1203    """One of a competition's reusable rejection wordings (#2746).
1204
1205    ``body`` may interpolate only ``{{competition}}``, ``{{deadline}}`` and
1206    ``{{contact}}`` -- the event's facts, never a registrant's."""
1207
1208    id: str
1209    competition_id: str
1210    label: dict[str, str] = Field(default_factory=dict)
1211    body: dict[str, str] = Field(default_factory=dict)
1212    created_at: datetime | None = None
1213    updated_at: datetime | None = None

One of a competition's reusable rejection wordings (#2746).

body may interpolate only {{competition}}, {{deadline}} and {{contact}} -- the event's facts, never a registrant's.

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
label: dict[str, str] = PydanticUndefined
body: dict[str, str] = PydanticUndefined
created_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
class RegistrationMessageTemplateWrite(ctfy.core.models.CtfyModel):
1225class RegistrationMessageTemplateWrite(CtfyModel):
1226    """Create or replace one template. Both maps are ``{locale: text}``; a
1227    body with any placeholder outside the closed set is refused
1228    ``422 review_template_placeholder``."""
1229
1230    label: dict[str, str] = Field(default_factory=dict)
1231    body: dict[str, str] = Field(default_factory=dict)
1232
1233    _v_label = field_validator("label", mode="before")(
1234        lambda v: _template_text(v, cap=MAX_TEMPLATE_LABEL_CHARS, field="label")
1235    )
1236    _v_body = field_validator("body", mode="before")(
1237        lambda v: _template_text(v, cap=MAX_TEMPLATE_BODY_CHARS, field="body")
1238    )

Create or replace one template. Both maps are {locale: text}; a body with any placeholder outside the closed set is refused 422 review_template_placeholder.

label: dict[str, str] = PydanticUndefined
body: dict[str, str] = PydanticUndefined
class RegistrationMessageTemplates(ctfy.core.models.CtfyModel):
1216class RegistrationMessageTemplates(CtfyModel):
1217    """``GET /admin/competitions/{id}/review-templates``: the whole set,
1218    oldest first, and the placeholders a body may use."""
1219
1220    competition_id: str
1221    templates: list[RegistrationMessageTemplate] = Field(default_factory=list)
1222    placeholders: list[str] = Field(default_factory=list)

GET /admin/competitions/{id}/review-templates: the whole set, oldest first, and the placeholders a body may use.

competition_id: str = PydanticUndefined
templates: list[RegistrationMessageTemplate] = PydanticUndefined
placeholders: list[str] = PydanticUndefined
class RegistrationReviewNote(ctfy.core.models.CtfyModel):
696class RegistrationReviewNote(CtfyModel):
697    """One reviewer note on a team (#2767 PR 4). Reviewers and organisers only.
698
699    ``author_name`` is ``""`` for an erased account.
700    """
701
702    id: str
703    author_id: str = ""
704    author_name: str = ""
705    body: str = ""
706    created_at: datetime | None = None

One reviewer note on a team (#2767 PR 4). Reviewers and organisers only.

author_name is "" for an erased account.

id: str = PydanticUndefined
author_id: str = ''
author_name: str = ''
body: str = ''
created_at: datetime.datetime | None = None
class RegistrationReviewNoteRequest(ctfy.core.models.CtfyModel):
721class RegistrationReviewNoteRequest(CtfyModel):
722    """``POST .../review-notes``: the note's words, trimmed, never empty."""
723
724    body: str = Field(min_length=1, max_length=4000)
725
726    @field_validator("body")
727    @classmethod
728    def _not_blank(cls, v: str) -> str:
729        v = v.strip()
730        if not v:
731            raise ValueError("a note needs words")
732        return v

POST .../review-notes: the note's words, trimmed, never empty.

body: str = PydanticUndefined
class RegistrationReviewNotes(ctfy.core.models.CtfyModel):
709class RegistrationReviewNotes(CtfyModel):
710    """``GET /admin/competitions/{id}/teams/{id}/review-notes``, oldest first.
711
712    The whole set for the team -- notes are few and never paged. Audited
713    (``registration_pii_read``, ``action="review_notes"``).
714    """
715
716    competition_id: str
717    team_id: str
718    notes: list[RegistrationReviewNote] = Field(default_factory=list)

GET /admin/competitions/{id}/teams/{id}/review-notes, oldest first.

The whole set for the team -- notes are few and never paged. Audited (registration_pii_read, action="review_notes").

competition_id: str = PydanticUndefined
team_id: str = PydanticUndefined
notes: list[RegistrationReviewNote] = PydanticUndefined
class RegistrationReviewProgress(ctfy.core.models.CtfyModel):
947class RegistrationReviewProgress(CtfyModel):
948    """The verdicts organisers gave since an instant the browser named -- its
949    own local midnight for "today" (#2540). Counts and reviewer names only."""
950
951    since: datetime
952    approved: int = 0
953    rejected: int = 0
954    by_reviewer: list[ReviewerVerdictCount] = Field(default_factory=list)

The verdicts organisers gave since an instant the browser named -- its own local midnight for "today" (#2540). Counts and reviewer names only.

since: datetime.datetime = PydanticUndefined
approved: int = 0
rejected: int = 0
by_reviewer: list[ReviewerVerdictCount] = PydanticUndefined
class RegistrationRosterRow(ctfy.core.models.CtfyModel):
263class RegistrationRosterRow(CtfyModel):
264    """One entrant on the organiser's roster.
265
266    This is the **only** wire model that carries the sensitive fields, and
267    it is only ever returned from a route gated on
268    ``require_registration_reviewer``. Anything added here is, by
269    construction, organiser-visible personal data — think before widening
270    it.
271    """
272
273    user_id: str
274    display_name: str = ""
275    email: str = ""
276    team_id: str = ""
277    team_name: str = ""
278    is_captain: bool = False
279
280    real_name: str = ""
281    phone: str = ""
282    mailing_address: str = ""
283    # ⚠️ The same closed set the request carries, not a bare ``str`` —
284    # the `max_level` shape, where the middle layer drops the type and
285    # the client then asserts more than the server promised.
286    gender: Literal["", "male", "female", "other", "undisclosed"] = ""
287    organization: str = ""
288    academic_stage: str = ""
289    grade: str = ""
290    undergrad_school: str = ""
291    specialties: list[str] = Field(default_factory=list)
292
293    submitted_at: datetime | None = None
294    review_status: RegistrationReviewStatus = "approved"
295    #: How this member stands against an institution-restricted event
296    #: (#2184). ``""`` = the event is not restricted -- never "eligible".
297    eligibility: RosterEligibility = ""
298    #: The accepted domain their proved address is at (``verified`` only).
299    eligibility_domain: str = ""
300    #: The role of a CARSI binding at an accepted school that a students-only
301    #: event refused (#2293); ``"unknown"`` = it released none; ``""`` otherwise.
302    eligibility_carsi_refused_role: str = ""
303    #: The school this member has proved, for any event (#2293); ``via == ""`` is
304    #: *not verified*, which is not the same claim as "not at a school".
305    school: SchoolIdentity = Field(default_factory=SchoolIdentity)
306    #: Joined the team after its last verdict (#2293) -- the organiser's cue to
307    #: read this member first. ``False`` for a team nobody has ruled on.
308    joined_after_review: bool = False
309    joined_at: datetime | None = None

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'
eligibility: Literal['', 'verified', 'invited', 'lapsed'] = ''
eligibility_domain: str = ''
eligibility_carsi_refused_role: str = ''
school: SchoolIdentity = PydanticUndefined
joined_after_review: bool = False
joined_at: datetime.datetime | None = None
class RegistrationSubmitRequest(ctfy.core.models.CtfyModel):
145class RegistrationSubmitRequest(CtfyModel):
146    """Body for ``PUT /competitions/{id}/registration``.
147
148    Every field is optional at the type level; which ones are actually
149    mandatory is decided per competition at request time against
150    ``registration_fields``. Encoding "required" in the type would hard-
151    code one event's rules into the wire contract.
152    """
153
154    real_name: str = Field(default="", max_length=120)
155    phone: str = Field(default="", max_length=40)
156    mailing_address: str = Field(default="", max_length=500)
157    gender: Literal["", "male", "female", "other", "undisclosed"] = ""
158    organization: str = Field(default="", max_length=200)
159    academic_stage: str = Field(default="", max_length=80)
160    grade: str = Field(default="", max_length=80)
161    undergrad_school: str = Field(default="", max_length=200)
162    specialties: list[str] = Field(default_factory=list)
163    # Captain-only; ignored (not rejected) when a non-captain sends them,
164    # so a member re-submitting a form the wizard prefilled does not 422.
165    team_honors: str = Field(default="", max_length=2000)
166
167    # ⚠️ **``None`` means "the form did not ask", and these two are the
168    # deliberate exception to this model's full-replace semantics.**
169    # Every other field here defaults to ``""`` because the wizard
170    # renders all of them and submits all of them, so an omission really
171    # is a clear. That reading is catastrophic for a privacy flag: a
172    # client that predates the field cannot render it, so a ``False``
173    # default would make every save from an older tab publish an entrant
174    # who asked to be withheld — silently, and with no way to know it
175    # happened. Absent is untouched.
176    #:
177    #: ``anonymous`` is the entrant's own name; ``team_anonymous`` is the
178    #: squad's row and is captain-only, ignored (not rejected) from a
179    #: member for the same reason ``team_honors`` is.
180    anonymous: bool | None = None
181    team_anonymous: bool | None = None
182    #: Withhold this entrant's **organisation** from the event's public
183    #: surfaces while their name stays (#2458) — a separate axis from
184    #: ``anonymous``, offered at every event. Tri-state for the same reason.
185    hide_organization: bool | None = None
186
187    @field_validator("phone")
188    @classmethod
189    def _check_phone(cls, v: str) -> str:
190        return validate_phone_shape(v)
191
192    @field_validator("specialties")
193    @classmethod
194    def _check_specialties(cls, v: list[str]) -> list[str]:
195        if len(v) > 12:
196            raise ValueError("specialties may contain at most 12 entries")
197        if any(len(s) > 40 for s in v):
198            raise ValueError("each specialty must be at most 40 characters")
199        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
hide_organization: bool | None = None
class RegistrationSummary(ctfy.core.models.CtfyModel):
895class RegistrationSummary(CtfyModel):
896    """Per-verdict totals for one competition's roster.
897
898    Split from the roster itself because the filter tabs need counts for
899    the *whole* competition while the list is paged — counting the rows
900    in hand would make every tab report the page size. It is also far
901    cheaper: a grouped ``COUNT`` rather than a page of decrypted
902    dossiers, which is why it can be fetched on every tab render.
903
904    Carries no personal data by construction, so it is not audited as a
905    PII read.
906    """
907
908    competition_id: str
909    total: int = 0
910    pending: int = 0
911    approved: int = 0
912    rejected: int = 0
913    #: ``pending`` teams that were rejected, edited and came back (#2515).
914    resubmitted: int = 0
915    #: ``pending`` teams reopened because somebody joined after approval.
916    new_member: int = 0
917    # Teams with at least one member missing a required answer. Counted
918    # in SQL against blank columns, never by reading the dossiers — an
919    # unanswered sealed field is stored empty, so this stays a count and
920    # not a bulk decrypt. Teams rather than people because that is the
921    # unit a reviewer works through.
922    incomplete_teams: int = 0
923    #: The condition filter's counts (#2767 A8), over the teams the
924    #: ``conditions_for`` scope names (every team without one); ``None`` = not
925    #: asked for. Counts only -- the dossiers are read, nothing crosses.
926    conditions: RegistrationConditionCounts | None = None
927    #: The places and the waitlist (#2767 PR 6); ``None`` = no cap.
928    capacity: RegistrationCapacity | None = None
929    #: ``pending`` teams that entered the queue ``long_wait_hours`` or more ago
930    #: (#2540), by the server's clock at this read -- never a stored flag.
931    long_wait: int = 0
932    #: The line the desk marks a waiting team at; the same constant counts it.
933    long_wait_hours: int = int(LONG_WAIT_AFTER.total_seconds() // 3600)
934    #: Verdicts given since ``verdicts_since`` (#2540); ``None`` = not asked.
935    verdicts: RegistrationReviewProgress | None = None

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
resubmitted: int = 0
new_member: int = 0
incomplete_teams: int = 0
conditions: RegistrationConditionCounts | None = None
capacity: RegistrationCapacity | None = None
long_wait: int = 0
long_wait_hours: int = 24
verdicts: RegistrationReviewProgress | None = None
class RegistrationTeamDetail(ctfy.core.models.CtfyModel):
681class RegistrationTeamDetail(CtfyModel):
682    """``GET /admin/competitions/{id}/teams/{id}/registration`` (#2767 PR 4).
683
684    One team's queue row, for the team and member detail pages, beside the
685    fields this event collects (policy and the sensitive mark) so a page can
686    mark a required answer left blank. Personal data, so the read is audited
687    (``registration_pii_read``, ``action="team_detail"``) exactly as the
688    roster page it is one row of.
689    """
690
691    competition_id: str
692    team: RegistrationTeamRow
693    fields: list[RegistrationFieldSpec] = Field(default_factory=list)

GET /admin/competitions/{id}/teams/{id}/registration (#2767 PR 4).

One team's queue row, for the team and member detail pages, beside the fields this event collects (policy and the sensitive mark) so a page can mark a required answer left blank. Personal data, so the read is audited (registration_pii_read, action="team_detail") exactly as the roster page it is one row of.

competition_id: str = PydanticUndefined
team: RegistrationTeamRow = PydanticUndefined
fields: list[RegistrationFieldSpec] = PydanticUndefined
class RegistrationTeamRow(ctfy.core.models.CtfyModel):
603class RegistrationTeamRow(CtfyModel):
604    """One squad on the organiser's review queue.
605
606    Review is a team-level verdict, so the queue is grouped by team with
607    the members nested — an organiser approves a squad after reading all
608    of its members' details, never one person at a time.
609    """
610
611    team_id: str
612    team_name: str = ""
613    description: str = ""
614    honors: str = ""
615    has_logo: bool = False
616    captain_user_id: str = ""
617    created_at: datetime | None = None
618    review_status: RegistrationReviewStatus = "approved"
619    reviewed_by: str = ""
620    reviewed_by_name: str = ""
621    reviewed_at: datetime | None = None
622    review_note: str = ""
623    members: list[RegistrationRosterRow] = Field(default_factory=list)
624    # Members who have not completed the form yet. Surfaced as a count so
625    # the reviewer can spot an incomplete squad without opening it.
626    incomplete_members: int = 0
627    # True when a member's dossier changed after the verdict was recorded,
628    # which makes that verdict stale. Derived from ``reviewed_at`` against
629    # the members' ``updated_at`` rather than stored: a column would have
630    # to be reset on every review and cleared on every edit, and a missed
631    # write there reads exactly like "nothing changed". The alternative —
632    # reverting the team to ``pending`` on any edit — would force a
633    # re-review over a corrected phone digit.
634    edited_after_review: bool = False
635    #: Why a ``pending`` team is waiting again (#2515): a rejected team was
636    #: edited (``resubmitted``), or somebody joined an approved team
637    #: (``new_member``). ``""`` for every other team. Two causes, never one
638    #: marker -- the reviewer's work in each is different.
639    reopened_for: ReopenCause | Literal[""] = ""
640    #: The team's place on the waitlist, 1-based (#2767 PR 6); ``0`` = not on it.
641    waitlist_position: int = 0
642    #: Members who joined after the verdict; what "新成员待审 · N 人" counts.
643    new_member_count: int = 0
644    #: The newest rejection on record, ``None`` for a team never rejected.
645    last_rejection: LastRejection | None = None
646    #: Renames, newest first -- an organiser reading a dispute asks what the
647    #: team was called *then* (#2238).
648    name_changes: list[TeamNameChangeRow] = Field(default_factory=list)
649    #: What the roster search ``q`` hit on this team (#2518); empty without ``q``.
650    matched: list[RegistrationMatch] = Field(default_factory=list)
651    #: What a verdict on this row must hand back (#2521): opaque, derived on
652    #: the server from the facts the reviewer is reading. A verdict whose
653    #: token no longer names the team's facts is refused ``409 review_stale``.
654    review_token: str = ""
655    #: Rejections on record plus one (#2524).
656    review_round: int = 1
657    #: When the team last entered the queue (``teams.review_requested_at``),
658    #: ISO 8601; ``""`` for a team that is not waiting.
659    pending_since: str = ""
660    #: The answers that still differ from what the last verdict saw; empty for
661    #: a team never ruled on. Field names only -- the timeline has the values.
662    changes_since_verdict: list[ChangedSinceVerdict] = Field(default_factory=list)
663    #: The caller belongs to this team (member or captain, #2542): the server
664    #: refuses their verdict on it (``403 review_own_team``) and the desk
665    #: disables the buttons. Decided on the server; ``False`` where the
666    #: projection has no viewer (the CSV export).
667    is_own_team: bool = False
668    #: The caller's conflict of interest with this team (#2429), advisory:
669    #: ``member`` is the squad ``is_own_team`` refuses, ``former_member`` one
670    #: they joined or left earlier in this event (the registration history's
671    #: membership rows), ``""`` none or no viewer.
672    reviewer_relation: ReviewerRelation = ""
673    #: The team against the registration conditions, with the member x
674    #: condition matrix (#2767). ``None`` = not computed (never "eligible").
675    conditions: TeamConditionsVerdict | None = None
676    #: Open 「互斥冲突」 flags (#2882), one per other competition; empty for
677    #: none. An approval resolves them; bulk approval skips a team with any.
678    exclusion_conflicts: list[ExclusionConflictInfo] = Field(default_factory=list)

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
reopened_for: Union[Literal['resubmitted', 'new_member', 'overdue'], Literal['']] = ''
waitlist_position: int = 0
new_member_count: int = 0
last_rejection: LastRejection | None = None
name_changes: list[TeamNameChangeRow] = PydanticUndefined
matched: list[RegistrationMatch] = PydanticUndefined
review_token: str = ''
review_round: int = 1
pending_since: str = ''
changes_since_verdict: list[ChangedSinceVerdict] = PydanticUndefined
is_own_team: bool = False
reviewer_relation: Literal['', 'member', 'former_member'] = ''
conditions: TeamConditionsVerdict | None = None
exclusion_conflicts: list[ExclusionConflictInfo] = PydanticUndefined
class RegistrationTodoInfo(ctfy.core.models.CtfyModel):
1132class RegistrationTodoInfo(CtfyModel):
1133    """One of the caller's own to-dos (#2527).
1134
1135    ``user_id`` ``""`` is a squad-level item the caller holds as captain.
1136    ``current_value`` is the caller's *own* stored answer for a field item --
1137    theirs, or the squad's for a captain -- and ``None`` for every other item:
1138    a teammate's value never travels here.
1139    """
1140
1141    id: str
1142    competition_id: str
1143    team_id: str
1144    user_id: str = ""
1145    item: RegistrationTodoItem
1146    field: str = ""
1147    note: str = ""
1148    status: RegistrationTodoStatus = "open"
1149    created_at: datetime | None = None
1150    done_at: datetime | None = None
1151    current_value: str | None = None

One of the caller's own to-dos (#2527).

user_id "" is a squad-level item the caller holds as captain. current_value is the caller's own stored answer for a field item -- theirs, or the squad's for a captain -- and None for every other item: a teammate's value never travels here.

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
team_id: str = PydanticUndefined
user_id: str = ''
item: Literal['field', 'verify_school_email', 'add_member'] = PydanticUndefined
field: str = ''
note: str = ''
status: Literal['open', 'done', 'cancelled'] = 'open'
created_at: datetime.datetime | None = None
done_at: datetime.datetime | None = None
current_value: str | None = None
class RegistryGcStatus(ctfy.core.models.CtfyModel):
130class RegistryGcStatus(CtfyModel):
131    """Whether and how the registry can reclaim the blobs behind deletes.
132
133    Deleting a manifest frees only its reference; the bytes stay until the
134    registry garbage-collects. This is the probe + preview the operator
135    reads before running that — it never runs GC itself, because a stock
136    ``registry:2`` exposes no remote GC and the platform holds no shell on
137    the registry container.
138    """
139
140    reachable: bool = False
141    #: The ``Docker-Distribution-Api-Version`` the registry advertised.
142    api_version: str = ""
143    mode: RegistryGcMode = RegistryGcMode.UNAVAILABLE
144    #: Bytes the current reclaim policies would free once their manifests
145    #: are deleted and GC has run — a preview, summed over both policies.
146    reclaimable_bytes: int = 0
147    reclaimable_manifests: int = 0
148    #: The exact host command an operator runs to garbage-collect, or
149    #: ``""`` when the registry is unreachable.
150    command: str = ""
151    #: A human-readable summary of the mechanism and any caveat.
152    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):
56class RegistryManifestRow(CtfyModel):
57    """One tag, with why it is protected and whether a reclaim would take it."""
58
59    tag: str
60    #: The manifest digest a delete addresses; ``""`` if it could not be
61    #: read (which leaves it un-deletable, the safe direction).
62    digest: str = ""
63    size_bytes: int = 0
64    #: The image config's ``created`` time — the closest the registry API
65    #: offers to a push time; ``None`` when it could not be read.
66    created_at: AwareDatetime | None = None
67    kind: RegistryRepoKind
68    #: Why this manifest may not be reclaimed. Empty means nothing
69    #: references it.
70    references: list[RegistryReferenceReason] = Field(default_factory=list)
71    #: ⚠️ Composed server-side from the reference set and the reclaim
72    #: policies, never re-derived in the browser — the same rule
73    #: ``RegistryUsageRow.over_quota`` follows. A ``true`` here means some
74    #: policy would remove it; the manual delete still refuses a pinned
75    #: digest regardless.
76    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):
1276class RegistryPushTarget(CtfyModel):
1277    """Where this account pushes an agent image, if anywhere.
1278
1279    ⚠️ ``enabled`` is composed **server-side** even though a non-empty
1280    ``repository_prefix`` implies it. A component could derive it — and
1281    then two clients disagree about whether the deployment has a
1282    registry. Same rule as ``expects_report``: the browser is handed the
1283    verdict, not the ingredients.
1284
1285    ⚠️ ``enabled`` means *a push would work*, not "an operator set a
1286    hostname". A domain with no signing pair behind it is a registry that
1287    refuses every login, and a page that printed a push command for one
1288    would be sending users at a wall.
1289    """
1290
1291    enabled: bool = False
1292    #: Blank whenever ``enabled`` is false — a half-filled answer becomes
1293    #: ``docker push /uid/agent``, which fails on a malformed reference
1294    #: rather than on anything a reader could act on.
1295    host: str = ""
1296    #: ``<host>/<namespace>``. Built here rather than in the client
1297    #: because the namespace scheme is what ``registry_scopes`` decides
1298    #: and what the token route authorises against; a client
1299    #: concatenating its own keeps working until the two disagree.
1300    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):
1237class RegistryQuotaInfo(CtfyModel):
1238    """The ceiling the rows are judged against.
1239
1240    ⚠️ Sent with the report because a number with no ceiling beside it is
1241    not a report: "40 GiB" answers nothing, "40 of 50" is the question
1242    the operator opened the page to ask.
1243    """
1244
1245    #: ``0`` means the dimension is off — the same reading everywhere
1246    #: else, never "nothing allowed".
1247    max_bytes: int = 0
1248    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):
88class RegistryReclaimItem(CtfyModel):
89    """One manifest a reclaim run would remove, or refused to."""
90
91    repository: str
92    tag: str
93    digest: str = ""
94    size_bytes: int = 0
95    #: Populated on the refused list — why the policy kept it.
96    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):
110class RegistryReclaimPlan(CtfyModel):
111    """What one policy would remove (dry run) or removed (executed)."""
112
113    policy: RegistryReclaimPolicy
114    dry_run: bool = True
115    #: What would be / was removed, and what the policy protected.
116    reclaim: list[RegistryReclaimItem] = Field(default_factory=list)
117    refused: list[RegistryReclaimItem] = Field(default_factory=list)
118    reclaim_count: int = 0
119    reclaim_bytes: int = 0
120    refused_count: int = 0
121    #: How many were actually deleted (``0`` on a dry run). ⚠️ A separate
122    #: number from ``reclaim_count`` so a partial execution — some deletes
123    #: the registry refused — is visible rather than assumed complete.
124    deleted: int = 0
125    #: Whether the catalog walk behind the plan was capped or hit an
126    #: unreadable repository; a plan over an incomplete view under-removes.
127    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):
 99class RegistryReclaimRequest(CtfyModel):
100    """Ask a reclaim policy what it would remove, or to remove it.
101
102    ⚠️ ``dry_run`` defaults **true**: the preview is the default and the
103    destructive act is the explicit one, per the issue's "dry-run first".
104    """
105
106    policy: RegistryReclaimPolicy
107    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
50    #: When the walk behind this list began (#3504). The list is served from
51    #: the last walk for up to 15 minutes — one walk is ~4800 registry calls —
52    #: so a page can say how old it is. ``None`` only from an older server.
53    as_of: AwareDatetime | None = None

Every repository, for the browse page's landing list.

items: list[RegistryRepo] = PydanticUndefined
total: int = 0
incomplete: bool = False
as_of: pydantic.types.AwareDatetime | None = None
class RegistryTagList(ctfy.core.models.CtfyModel):
79class RegistryTagList(CtfyModel):
80    """The tags of one repository, for the browse drill-down."""
81
82    repository: str
83    items: list[RegistryManifestRow] = Field(default_factory=list)
84    total: int = 0
85    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):
1251class RegistryUsageReport(CtfyModel):
1252    """Who holds what in the platform's own registry."""
1253
1254    items: list[RegistryUsageRow] = Field(default_factory=list)
1255    quota: RegistryQuotaInfo = Field(default_factory=RegistryQuotaInfo)
1256    #: When the scan last *completed*, read off the receipt it writes on
1257    #: the far side of a finished walk. ⚠️ It used to be
1258    #: ``max(measured_at)`` over the rows, which cannot answer the
1259    #: question this field is for: with no rows that is `None` both for a
1260    #: registry nobody has pushed to and for a scan that has never once
1261    #: worked, and the page then says "not measured **yet**" about a loop
1262    #: that will never measure anything.
1263    scanned_at: AwareDatetime | None = None
1264    #: Whether the quota is deciding anything right now, composed
1265    #: **server-side** from that receipt. ⚠️ The verdict rather than the
1266    #: timestamp, for the reason ``RegistryPushTarget.enabled`` gives
1267    #: below: a page dividing a date by an interval is a second
1268    #: implementation of the rule, and the copy an operator reads would
1269    #: eventually disagree with the thing actually admitting pushes.
1270    #: ⚠️ Defaults to ``in_force`` so an older server does not paint an
1271    #: alarm across a page that is fine — the same direction
1272    #: ``MySolveSummary.ranked`` defaults in, and for the same reason.
1273    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):
1201class RegistryUsageRow(CtfyModel):
1202    """One account's registry footprint, as the operator's report shows it."""
1203
1204    user_id: str
1205    display_name: str = ""
1206    bytes: int = 0
1207    tags: int = 0
1208    repos: int = 0
1209    #: The repository names, so the reclaim entry point beside this table
1210    #: has something to point at. ⚠️ **Not read off the harness table.**
1211    #: The push grant covers ``<user>/*``, so an account holds
1212    #: repositories ctfy was never told about — which is precisely the
1213    #: storage nobody is watching, and precisely what a report built
1214    #: from registered images would omit.
1215    repositories: list[str] = Field(default_factory=list)
1216    measured_at: AwareDatetime | None = None
1217    #: ⚠️ Carried, never folded into the numbers. A namespace whose
1218    #: manifests half-failed *undercounts*, and an undercount presented
1219    #: as fact is how an operator concludes an account is fine when
1220    #: nobody could read half of it.
1221    incomplete: bool = False
1222    #: ⚠️ Composed **server-side** from the same ``check_quota`` the push
1223    #: path uses, never re-derived in the browser from the two numbers
1224    #: below. A page that did its own comparison would eventually
1225    #: disagree with the thing that actually refuses pushes — and the
1226    #: one nobody reads is the one that decides.
1227    over_quota: bool = False
1228    #: Whether this namespace is the deployment's platform account (the
1229    #: one ``CTFY_IMAGE`` names), whose images — the frozen ``chal-…``
1230    #: repositories, the deploy's own — are the platform's and are
1231    #: charged to nobody (#1863). ⚠️ Composed server-side from the same
1232    #: ``is_charged`` the push path asks, so the page cannot call a row
1233    #: exempt that the token route still narrows.
1234    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 ReleaseChallengeView(ctfy.core.models.CtfyModel):
71class ReleaseChallengeView(CtfyModel):
72    """One challenge of the plan."""
73
74    challenge_id: str
75    name: str = ""
76    wave: str = ""
77    #: Its own published rule ("单独设置"), or ``None`` (follows its wave).
78    rule: ReleaseRule | None = None
79    draft: ReleaseRule | None = None
80    status: ReleaseStatus = "released"
81    released_at: datetime | None = None
82    #: The forecast under the requested pace; ``None`` when nothing predicts it.
83    expected_at: datetime | None = None
84    #: A fallback time, if its rule has one ("最晚").
85    latest_at: datetime | None = None
86    close_at: datetime | None = None
87    #: How many teams have it released, for a per-team (闯关) rule.
88    teams_released: int = 0
89    hints: list[ReleaseHintMarker] = Field(default_factory=list)

One challenge of the plan.

challenge_id: str = PydanticUndefined
name: str = ''
wave: str = ''
rule: ctfy.core.release_conditions.ReleaseRule | None = None
draft: ctfy.core.release_conditions.ReleaseRule | None = None
status: Literal['released', 'scheduled', 'conditional', 'manual'] = 'released'
released_at: datetime.datetime | None = None
expected_at: datetime.datetime | None = None
latest_at: datetime.datetime | None = None
close_at: datetime.datetime | None = None
teams_released: int = 0
hints: list[ReleaseHintMarker] = PydanticUndefined
class ReleaseDraftBody(ctfy.core.models.CtfyModel):
137class ReleaseDraftBody(CtfyModel):
138    """Save the editor's draft for one subject (``wave:<name>`` or
139    ``challenge:<id>``)."""
140
141    subject: str = Field(..., min_length=3, max_length=320)
142    rule: ReleaseRule

Save the editor's draft for one subject (wave:<name> or challenge:<id>).

subject: str = PydanticUndefined
rule: ctfy.core.release_conditions.ReleaseRule = PydanticUndefined
class ReleaseFindingInfo(ctfy.core.models.CtfyModel):
50class ReleaseFindingInfo(CtfyModel):
51    """One problem the static check found. ``error`` refuses a publish."""
52
53    code: FindingCode
54    level: FindingLevel
55    subject: str
56    detail: str = ""

One problem the static check found. error refuses a publish.

code: Literal['unknown_target', 'cycle', 'close_before_release', 'never_automatic', 'more_teams_than_entrants', 'no_start', 'previous_hint_on_first', 'anchor_not_allowed', 'absolute_without_time', 'empty_wave', 'self_target_outside_hint', 'release_outside_session'] = PydanticUndefined
level: Literal['error', 'warning'] = PydanticUndefined
subject: str = PydanticUndefined
detail: str = ''
class ReleaseHintMarker(ctfy.core.models.CtfyModel):
59class ReleaseHintMarker(CtfyModel):
60    """One hint tier on the organiser's timeline (the 💡 markers)."""
61
62    hint_id: str
63    tier: int
64    status: ReleaseStatus
65    #: When it was released, or when it is expected to be (the forecast).
66    at: datetime | None = None
67    #: Whether its rule waits on progress (drawn dashed).
68    conditional: bool = False

One hint tier on the organiser's timeline (the 💡 markers).

hint_id: str = PydanticUndefined
tier: int = PydanticUndefined
status: Literal['released', 'scheduled', 'conditional', 'manual'] = PydanticUndefined
at: datetime.datetime | None = None
conditional: bool = False
class ReleasePlanView(ctfy.core.models.CtfyModel):
115class ReleasePlanView(CtfyModel):
116    """The organiser's whole release plan of a competition, with drafts and
117    a forecast. Organiser-only: to a player an unreleased challenge simply
118    does not exist."""
119
120    competition_id: str
121    starts_at: datetime | None = None
122    ends_at: datetime | None = None
123    now: datetime
124    #: Registered teams — the denominator a "N 支队伍" condition is read against.
125    entrants: int = 0
126    pace: PacePreset = "normal"
127    waves: list[ReleaseWaveView] = Field(default_factory=list)
128    challenges: list[ReleaseChallengeView] = Field(default_factory=list)
129    #: Findings over the plan **as it would be with every draft published**.
130    findings: list[ReleaseFindingInfo] = Field(default_factory=list)
131    hint_cost_mode: HintCostMode = "discount"
132    #: The mode that applies on this competition's board (``record`` when
133    #: the board prices nothing).
134    effective_hint_cost_mode: HintCostMode = "discount"

The organiser's whole release plan of a competition, with drafts and a forecast. Organiser-only: to a player an unreleased challenge simply does not exist.

competition_id: str = PydanticUndefined
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
now: datetime.datetime = PydanticUndefined
entrants: int = 0
pace: Literal['slow', 'normal', 'fast'] = 'normal'
waves: list[ReleaseWaveView] = PydanticUndefined
challenges: list[ReleaseChallengeView] = PydanticUndefined
findings: list[ReleaseFindingInfo] = PydanticUndefined
hint_cost_mode: Literal['discount', 'fixed', 'record'] = 'discount'
effective_hint_cost_mode: Literal['discount', 'fixed', 'record'] = 'discount'
ReleaseStatus = typing.Literal['released', 'scheduled', 'conditional', 'manual']
class ReleaseSubjectBody(ctfy.core.models.CtfyModel):
145class ReleaseSubjectBody(CtfyModel):
146    """Name one subject (publish its draft, or release it now)."""
147
148    subject: str = Field(..., min_length=3, max_length=320)

Name one subject (publish its draft, or release it now).

subject: str = PydanticUndefined
class ReleaseWaveCreate(ctfy.core.models.CtfyModel):
151class ReleaseWaveCreate(CtfyModel):
152    """Create a wave. ``challenge_ids`` are moved into it (their phase-1
153    batch label becomes ``name``)."""
154
155    name: str = Field(..., min_length=1, max_length=60)
156    challenge_ids: list[str] = Field(default_factory=list, max_length=500)

Create a wave. challenge_ids are moved into it (their phase-1 batch label becomes name).

name: str = PydanticUndefined
challenge_ids: list[str] = PydanticUndefined
class ReleaseWaveView(ctfy.core.models.CtfyModel):
 92class ReleaseWaveView(CtfyModel):
 93    """One wave (batch) of the plan."""
 94
 95    name: str
 96    position: int = 0
 97    challenge_ids: list[str] = Field(default_factory=list)
 98    rule: ReleaseRule | None = None
 99    draft: ReleaseRule | None = None
100    draft_saved_at: datetime | None = None
101    published_at: datetime | None = None
102    status: ReleaseStatus = "released"
103    released_at: datetime | None = None
104    #: Earliest instant the rule can fire given what is known now (a time
105    #: atom, or progress already made).
106    earliest_at: datetime | None = None
107    expected_at: datetime | None = None
108    latest_at: datetime | None = None
109    close_at: datetime | None = None
110    #: For a per-team rule: how many teams have it.
111    teams_released: int = 0
112    per_team: bool = False

One wave (batch) of the plan.

name: str = PydanticUndefined
position: int = 0
challenge_ids: list[str] = PydanticUndefined
rule: ctfy.core.release_conditions.ReleaseRule | None = None
draft: ctfy.core.release_conditions.ReleaseRule | None = None
draft_saved_at: datetime.datetime | None = None
published_at: datetime.datetime | None = None
status: Literal['released', 'scheduled', 'conditional', 'manual'] = 'released'
released_at: datetime.datetime | None = None
earliest_at: datetime.datetime | None = None
expected_at: datetime.datetime | None = None
latest_at: datetime.datetime | None = None
close_at: datetime.datetime | None = None
teams_released: int = 0
per_team: bool = False
class RenderedReviewMessage(ctfy.core.models.CtfyModel):
1241class RenderedReviewMessage(CtfyModel):
1242    """One template rendered for one team (#2746): the text the reviewer
1243    starts from and may edit before sending. ``language`` is the team's
1244    mail language, ``""`` = unknown (both translations, Chinese first)."""
1245
1246    template_id: str
1247    team_id: str
1248    language: MailLanguageOrUnset = ""
1249    text: str

One template rendered for one team (#2746): the text the reviewer starts from and may edit before sending. language is the team's mail language, "" = unknown (both translations, Chinese first).

template_id: str = PydanticUndefined
team_id: str = PydanticUndefined
language: Literal['', 'en', 'zh-CN'] = ''
text: str = PydanticUndefined
class RenewResponse(ctfy.core.models.CtfyModel):
687class RenewResponse(CtfyModel):
688    #: ⚠️ **An acknowledgement, not a lifecycle status.** Its two
689    #: siblings above report an :class:`InstanceStatus`; ``"renewed"`` is
690    #: not one — a renewed instance is still ``ready``. The field name
691    #: was the only thing suggesting otherwise, and a shared name across
692    #: three responses is what made the difference invisible. Typed as
693    #: the constant it is, so the next reader does not have to work out
694    #: which vocabulary this one speaks.
695    status: Literal["renewed"] = "renewed"
696    id: str
697    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 ReplyRequest(ctfy.core.models.CtfyModel):
175class ReplyRequest(CtfyModel):
176    """Body for a reply. ``max_length`` is a transport ceiling; the server
177    normalises the text and refuses empty or over-long, never truncates."""
178
179    body: str = Field(max_length=MAX_ORGANISER_CHARS * 2)
180    #: Organisers only: also send the addressed side a mail reminder (title
181    #: and link, never the body). Ignored on a player's reply.
182    email: bool = False

Body for a reply. max_length is a transport ceiling; the server normalises the text and refuses empty or over-long, never truncates.

body: str = PydanticUndefined
email: bool = False
class ReportAttachmentInfo(ctfy.core.models.CtfyModel):
249class ReportAttachmentInfo(CtfyModel):
250    """One evidence file in a team's report appendix.
251
252    ⚠️ **No bytes and no URL.** The bytes are fetched from
253    ``GET .../report/attachments/{id}`` — proxied through the platform
254    like a team logo, so the bucket needs no public-read policy and an
255    unpublished competition's evidence is not enumerable by anyone who
256    guesses a key. Embedding the object key here would publish exactly
257    that guess.
258    """
259
260    id: str = ""
261    #: The name the uploader gave, sanitised for display. ⚠️ Display
262    #: only — the object key is derived from ``id``, so this string
263    #: addresses nothing.
264    filename: str = ""
265    #: **Sniffed from the bytes.** A real image type means a client may
266    #: render a thumbnail; ``application/octet-stream`` means the file is
267    #: a download and nothing more.
268    content_type: str = ""
269    size_bytes: int = 0
270    #: Who uploaded it — the team owns the evidence, the user is the
271    #: audit, the same split ``ReportInfo.user_id`` makes.
272    user_id: str = ""
273    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):
276class ReportAttachmentsResponse(CtfyModel):
277    """One engagement's appendix, oldest first.
278
279    Oldest first because a reference in the prose counts from the order
280    the team built the appendix in, and a newest-first list silently
281    renumbers it on every upload.
282    """
283
284    competition_id: str = ""
285    team_id: str = ""
286    challenge_id: str = ""
287    attachments: list[ReportAttachmentInfo] = Field(default_factory=list)
288    #: Whether this deployment can accept an upload at all.
289    #:
290    #: ⚠️ **An empty appendix has to say which emptiness it is.** With no
291    #: object storage configured the list route still answers (reading
292    #: back what you submitted is not a submission), so an empty array
293    #: reads as "this team uploaded nothing" when it means "nobody can" —
294    #: and the player only discovers the difference by trying. Same
295    #: argument as ``webhook_configured`` on the email-suppression list
296    #: and ``gateway_configured`` on the LLM budget roster.
297    #:
298    #: Defaults ``False`` because that is the answer that is *not* a new
299    #: claim: an older server sending nothing must not make a client
300    #: promise an upload the deployment cannot perform.
301    uploads_enabled: bool = False
302    #: How many more this engagement may take.
303    #:
304    #: Served rather than derived from ``len(attachments)`` against a
305    #: constant the client restates: a ceiling spelled in two places is
306    #: one a client eventually disagrees with, and the direction it
307    #: disagrees in is a disabled upload button on an engagement that had
308    #: room. Zero means the appendix is full — a real state, distinct
309    #: from an appendix that is merely empty.
310    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):
569class ReportClaimRow(CtfyModel):
570    """One finding a report claims that the platform could not place.
571
572    ``#1582``'s second operator question. ⚠️ **Never scored**: whether an
573    unintended path is a finding or a defect in the range is a judgement,
574    and a grader that scored it would be paying the model for the
575    scaffolding's mistakes.
576
577    ⚠️ **``quote`` is the team's prose and ``title`` is the model's
578    paraphrase.** A row only exists once the quote was located verbatim
579    in the report, so what an operator reads is always something the
580    team wrote — with a label attached for scanning.
581    """
582
583    #: Position in the report's own order, which is the model's
584    #: enumeration order. Published so a reader can cite a row.
585    ordinal: int = 0
586    title: str = ""
587    #: Empty is an ordinary answer: a finding that is a condition rather
588    #: than a value turns on no single string.
589    value: str = ""
590    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):
593class ReportClaimsPage(CtfyModel):
594    """What one team's report claims beyond what the range declares.
595
596    ⚠️ **``read`` is the field that makes the rest honest.** Zero rows
597    has three causes: the platform read the report and could place
598    everything, nothing ever looked, or the model call was refused. Only
599    the first is a fact about the team. Without this flag an operator
600    reading an empty table cannot tell "they found nothing we did not
601    plant" from "this deployment has no gateway", and those get opposite
602    responses.
603
604    ⚠️ **Defaults ``False``**, so a server that does not send it reads as
605    *not read* rather than as a clean sheet — the claims-nothing
606    direction, since the alarming reading here is the reassuring one.
607    """
608
609    #: Which saved version was read. ``0`` when nothing has been.
610    report_version: int = 0
611    #: Has any version of this report been read at all.
612    read: bool = False
613    #: The rows this page carries, already cut to the operator's setting.
614    rows: list[ReportClaimRow] = Field(default_factory=list)
615    #: How many the reading found, before the cut. ⚠️ A route that takes
616    #: a limit owes a total — ``rows`` shorter than this is a list that
617    #: was cut, and without the number a short list reads as a quiet
618    #: report.
619    total: int = 0
620    #: How many claims the platform *could* tie to a declared question.
621    #: Published because it is what says the subtraction ran at all: a
622    #: long list beside ``accounted: 0`` is a reading with nothing to
623    #: subtract against, which is a different problem from a range with
624    #: unintended paths in it.
625    accounted: int = 0
626    #: How many spans the model produced that are not in the report.
627    #: ⚠️ A signal about the *deployment*, never about the team — it
628    #: counts the model inventing, and its sibling on the grading side
629    #: is ``unreadable_answers``.
630    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):
348class ReportGradeInfo(CtfyModel):
349    """One grading run in full, per-fact verdicts included.
350
351    What an appeal argues from, so it carries the half a count cannot:
352    *which* finding was called inaccurate. Organiser-only —
353    ``unsupported`` beside a fact key is an accusation with a name on it.
354    """
355
356    team_id: str = ""
357    challenge_id: str = ""
358    report_version: int = 0
359    #: Per-fact verdicts keyed ``challenge_id:question_id``. Kept whole
360    #: rather than only the non-``accurate`` ones, so a run states the
361    #: entire fact set it was computed over — otherwise an absent key
362    #: means both "graded accurate" and "was not a fact at the time".
363    verdicts: dict[str, str] = Field(default_factory=dict)
364    accurate: int = 0
365    inaccurate: int = 0
366    not_mentioned: int = 0
367    unsupported: int = 0
368    points: int = 0
369    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):
313class ReportGradeSummary(CtfyModel):
314    """One team's live Reporting verdict on one range, without the detail.
315
316    The counts and the price, for a roster row and a wall panel. The
317    per-fact verdicts are one route further in
318    (:class:`ReportGradeInfo`), the same split the report body itself
319    makes: a table of "how did everyone do" must not carry thirty-three
320    judgements per row.
321
322    ⚠️ **``None`` on a row, never an all-zero summary.** A report graded
323    against an empty fact set records exactly this object with every
324    count at zero — a real verdict, produced with no model call, about a
325    team that wrote up a range it never got into — so a zeroed default
326    would be indistinguishable from *never graded*. The two are opposite
327    facts about whether the platform owes the team anything.
328    """
329
330    #: Which saved version this verdict is about. ⚠️ Compare it with the
331    #: row's own ``version``: a team that has saved again since is
332    #: reading a judgement of a draft they have replaced, and that is
333    #: the whole reason the grade row records a version at all.
334    report_version: int = 0
335    accurate: int = 0
336    inaccurate: int = 0
337    not_mentioned: int = 0
338    #: Findings claimed with nothing behind them — §3.4's fabrication
339    #: count, and the reason this whole object is organiser-facing.
340    unsupported: int = 0
341    #: What those counts were worth **under the weights in force when
342    #: the verdict was produced**. Never re-priced downstream: a model's
343    #: verdict is not reproducible, so that row is its only account.
344    points: int = 0
345    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):
372class ReportGradesResponse(CtfyModel):
373    """Every grading run on one team's report for one range, newest first.
374
375    ⚠️ **Its own organiser route rather than a field on
376    :class:`ReportVersionsResponse`**, which a player reads for their own
377    history. Widening that one would make an empty ``runs`` list mean
378    both "never graded" and "not disclosed to you" — and the route that
379    must never serve these would then be one forgotten branch away from
380    doing it, instead of being structurally unable to.
381    """
382
383    competition_id: str = ""
384    team_id: str = ""
385    challenge_id: str = ""
386    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):
34class ReportInfo(CtfyModel):
35    """One saved version, as its own team reads it back.
36
37    ⚠️ ``content`` is attacker-authored text — every byte is written by
38    whoever is attacking the range. It is carried and stored as plain
39    markdown, never rendered as HTML by anything that receives it, and
40    when the grading half lands it reaches a model only through the
41    bounded matching prompt described in the plan's §5.4.
42    """
43
44    competition_id: str = ""
45    team_id: str = ""
46    #: The range this report is about. ⚠️ Empty on a **pre-split** row —
47    #: one written before the report was per challenge, when it covered
48    #: the whole competition. Those are read back unchanged; nothing
49    #: writes an empty one any more.
50    challenge_id: str = ""
51    #: Who pressed save on *this* version. The team is scored; the user
52    #: is the audit.
53    user_id: str = ""
54    #: 1-based, monotonic within (team, competition, challenge). The
55    #: current report is the highest; nothing renumbers.
56    version: int = 0
57    content: str = ""
58    saved_at: datetime | None = None
59    #: When the team asked for **this version** to be judged now rather
60    #: than when the quiet window elapses. ``None`` is the ordinary
61    #: state; a save writes a fresh version without it, because a team
62    #: that carried on writing has withdrawn the claim that the document
63    #: was ready.
64    grading_requested_at: datetime | None = None
65    #: When the team handed this version in — 交卷. While it is
66    #: set, the save route refuses a new version.
67    #:
68    #: ⚠️ **The timestamp, never a bool.** "When did they hand it in" is
69    #: the question a late-submission dispute asks, and it is the one a
70    #: boolean cannot answer at all.
71    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):
411class ReportPipeline(CtfyModel):
412    """The whole deployment's report-grading pipeline, in one read.
413
414    The competition-scoped roster answers "is *my* event moving"; this
415    answers "is the grader moving at all", which is a different question
416    with a different failure. A backlog nothing drains is byte-identical
417    to a busy one — the job leaves an ungradeable report due on purpose
418    and records ``succeeded`` while doing it — so the two facts that
419    decide whether a verdict can be produced travel beside the numbers
420    rather than being inferred from them.
421    """
422
423    #: Due across every exercise competition.
424    backlog: int = 0
425    #: Saved and ungraded but not yet quiet, across every one of them.
426    waiting: int = 0
427    #: In the job's own walk order; see :class:`ReportPipelineEvent`.
428    events: list[ReportPipelineEvent] = Field(default_factory=list)
429    #: How many reports one tick will grade, across the whole platform.
430    #: Served because it is what turns a backlog into a *duration*, and
431    #: it is a constant the client would otherwise have to restate.
432    max_per_tick: int = 0
433    #: How often that tick comes round, in seconds.
434    interval_seconds: int = 0
435    #: How long a report must sit untouched to become due.
436    quiet_seconds: int = 0
437    #: How long the oldest **due** report has been waiting, in seconds.
438    #:
439    #: ⚠️ **The reading this model's own last paragraph asks for.** It
440    #: says what proves the pipeline is working is "the backlog going
441    #: down" — which one snapshot cannot show, because a backlog nothing
442    #: drains and a busy one are the same two counts. This is the fact
443    #: that separates them without a second sample.
444    #:
445    #: ⚠️ **``None`` covers two absences and ``0.0`` covers neither**:
446    #: an empty queue has no oldest row, and a queue whose due rows are
447    #: all unstamped has one with no readable age. A zero would read as
448    #: *a report arrived a moment ago*, the opposite of *nothing is
449    #: due*, and the two get opposite responses. ``backlog`` beside it
450    #: is what tells an absent age apart from a server that did not say.
451    #:
452    #: ⚠️ **Platform-wide, and deliberately not on
453    #: :class:`ReportPipelineEvent`.** A per-event age is a true fact
454    #: that reads as a verdict about that event, while the cause is
455    #: usually the events *ahead* of it in the one shared per-tick
456    #: ceiling — which is the misreading this whole model exists to
457    #: prevent.
458    oldest_due_seconds: float | None = None
459    #: Is the recurring loop switched on for this deployment. ⚠️ Both
460    #: gates default ``False``, the answer that is not a new claim: an
461    #: older server's silence must not render as "grading is running".
462    grading_enabled: bool = False
463    #: Is there an LLM gateway for the grader's own grant to be spent
464    #: through. Without one the grader returns before building a prompt
465    #: and every report simply stays due.
466    grading_configured: bool = False
467    #: When the loop last ran, and what it said. ⚠️ **Its own report, so
468    #: it is evidence rather than health** — the job records ``ok`` for a
469    #: run that caught its own failure and returned the message. It is
470    #: published so an operator can see the pipeline is *ticking*; what
471    #: says it is *working* is the backlog going down.
472    last_run_at: datetime | None = None
473    #: ⚠️ Typed, not ``str``: it is a three-member closed set
474    #: (``ok`` / ``error`` / ``""`` for never run), and ``""`` here is
475    #: *never run on this deployment* rather than a fourth outcome.
476    last_status: JobRunStatus = ""
477    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):
389class ReportPipelineEvent(CtfyModel):
390    """One exercise competition's share of the platform-wide backlog.
391
392    ⚠️ **In the order the grading job walks it**, never sorted by size.
393    ``MAX_REPORTS_PER_TICK`` is a ceiling *across every competition*, so
394    the job drains them in sequence and a competition late in that
395    sequence is starved by the ones ahead of it. A list ordered by
396    backlog would put the starving event first and read as the opposite
397    of what is happening.
398    """
399
400    competition_id: str = ""
401    title: str = ""
402    #: The title per locale when this row is a competition (#2230); a
403    #: browser renders this, ``title`` is its base-locale reading.
404    title_i18n: LocalizedText = Field(default_factory=dict)
405    #: Reports due right now — quiet, or pushed past the window by hand.
406    queued: int = 0
407    #: Saved, ungraded, and not yet quiet.
408    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 = ''
title_i18n: dict[str, str] = PydanticUndefined
queued: int = 0
waiting: int = 0
class ReportRosterResponse(ctfy.core.models.CtfyModel):
526class ReportRosterResponse(CtfyModel):
527    competition_id: str = ""
528    rows: list[ReportRosterRow] = Field(default_factory=list)
529    #: Teams registered for this competition that have saved nothing **at
530    #: all**, in any range. Served rather than derived: a client would
531    #: have to fetch the full team list to subtract, and two clients
532    #: doing that arithmetic differently is two organisers disagreeing
533    #: about who is missing.
534    #:
535    #: ⚠️ Deliberately *not* "unwritten ``(team, range)`` pairs". That
536    #: number is ``teams × ranges`` for an event nobody has started and
537    #: grows with a bucket competition's whole catalog, so it answers a
538    #: question no organiser asked while looking like the one they did.
539    missing_team_count: int = 0
540    #: How many reports are queued for grading right now.
541    #:
542    #: ⚠️ **Not served for the client's arithmetic** — it holds every row
543    #: and can count them — but because it is the *headline*: an
544    #: organiser wants "18 awaiting judgement" with no table underneath
545    #: it, and a tally the page did itself would be a second answer free
546    #: to disagree with the one the wall is showing the room.
547    queue_length: int = 0
548    #: Seconds a report must sit untouched before it becomes due, so the
549    #: page can say how long ``waiting`` lasts rather than only that it
550    #: is not over.
551    quiet_seconds: int = 0
552    #: Is the recurring grading loop switched on for this deployment.
553    #:
554    #: ⚠️ **Both of these travel with the backlog because the backlog
555    #: cannot say it.** A queue nothing is draining looks exactly like a
556    #: busy one — the job leaves an ungradeable report due on purpose,
557    #: and records ``succeeded`` while doing it — so an organiser reading
558    #: only the rows would see a healthy event right up until the
559    #: Reporting column stayed empty at the close. Defaults ``False``
560    #: because that is the answer that is not a new claim: an older
561    #: server's silence must not render as "grading is running".
562    grading_enabled: bool = False
563    #: Is there an LLM gateway for the platform's own grant to be spent
564    #: through. Without one the grader returns before building a prompt
565    #: and the report simply stays due.
566    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):
480class ReportRosterRow(CtfyModel):
481    """One team's report *status* on the organiser's roster.
482
483    ⚠️ **No body.** An event accumulates reports all week and a table of
484    "who has written one" must not read every one of them out of SQLite
485    to render a row count — the same argument that made
486    ``list_patch_submissions_admin`` name its columns rather than select
487    the entity. The body is one route further in, one team at a time.
488    """
489
490    team_id: str = ""
491    team_name: str = ""
492    #: The range. ⚠️ Carried rather than named: a ``challenge_id`` is
493    #: already human-readable (``GAUNTLET-FIN-001``), unlike a team's
494    #: uuid, so a second lookup would buy nothing — the same reading
495    #: ``EntityCell``'s own note records.
496    challenge_id: str = ""
497    version: int = 0
498    #: Length of the current version, in characters. What an organiser
499    #: scans the roster for: who has written nothing, who has written a
500    #: paragraph, who has written a report.
501    length: int = 0
502    saved_at: datetime | None = None
503    #: Where this write-up stands with the report grader.
504    status: ReportGradeStatus = "not_scored"
505    #: 1-based place in the grading queue, ``0`` unless ``status`` is
506    #: ``queued``. The organiser's is never withheld — the queue is the
507    #: thing they are on this page to manage.
508    queue_position: int = 0
509    #: The live verdict, or ``None`` where none has ever been produced.
510    #:
511    #: ⚠️ **A verdict beside a ``waiting`` or ``queued`` status is the
512    #: normal case, not a contradiction**: a team that saves a revision
513    #: after being graded is back in the queue while the previous
514    #: judgement is still the one the board reads.
515    #: ``grade.report_version`` against ``version`` is what says so.
516    grade: ReportGradeSummary | None = None
517    #: When this team handed this range in — 交卷 — or ``None``.
518    #:
519    #: ⚠️ **A different fact from every status here**, and the one an
520    #: organiser at the deadline is actually reading for: ``waiting``
521    #: covers both "still typing" and "finished ten minutes ago", which
522    #: are opposite answers to "is this team done?".
523    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):
21class ReportSaveRequest(CtfyModel):
22    """A new version of the caller's team report.
23
24    ⚠️ Deliberately **not** a patch/append shape. A report is one
25    document the team owns; sending the whole thing each time is what
26    makes "the current report" unambiguous, and what lets the server
27    keep every version without having to replay a chain of edits to
28    reconstruct any of them.
29    """
30
31    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):
104class ReportSaveResponse(CtfyModel):
105    """What one save did — the version it wrote, and what it credited.
106
107    ⚠️ **Its own model rather than a field on :class:`ReportInfo`.**
108    "How many findings did this save credit" is a fact about *this
109    write*; on a read, or on a row in the version history, the same field
110    would have to default to zero, which reads as "nothing here was worth
111    anything" about versions that may have been worth a great deal.
112    """
113
114    report: ReportInfo
115    #: Sentinels this save credited that the team did not already hold.
116    #:
117    #: This is the whole feedback channel the exercise format allows: a
118    #: player learns the last edit was worth something, and never learns
119    #: how many sentinels exist or how many remain
120    #: (``docs/gauntlet-exercise-plan.md`` §4).
121    newly_credited: int = 0
122    #: Where this save leaves the report with the report grader.
123    #:
124    #: ⚠️ **On the save rather than on :class:`ReportInfo`**, for the
125    #: reason ``newly_credited`` is: a queue standing is true of the
126    #: *current* report, and a row in the version history has none — a
127    #: field there would have to default to something, and every value
128    #: it could default to is a claim about a document that has been
129    #: superseded.
130    #:
131    #: A save always lands here as ``waiting`` in an exercise
132    #: competition (the save is what restarts the quiet window) and as
133    #: ``not_scored`` everywhere else, which is exactly the sentence a
134    #: player needs after pressing the button.
135    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):
 74class ReportStanding(CtfyModel):
 75    """Where one range's report stands — what every verb on it reads back.
 76
 77    ⚠️ **Read back from the store, never composed from the request.**
 78    ``admin_awd._info``'s rule: a reply assembled out of what the caller
 79    asked for says the write happened whether or not it did, and the two
 80    facts here that a client cannot recompute — the queue position and
 81    what is left of the grading quota — are exactly the ones a hopeful
 82    reply would get wrong.
 83    """
 84
 85    report: ReportInfo
 86    #: Where the current version stands with the report grader.
 87    status: ReportGradeStatus = "not_scored"
 88    #: 1-based place in the grading queue, ``0`` unless ``queued`` — and
 89    #: ``0`` in a silent competition too, which ``status`` tells apart.
 90    queue_position: int = 0
 91    #: The competition's whole backlog; ``0`` when silent.
 92    queue_length: int = 0
 93    #: How many times this team has pushed this range's report past the
 94    #: quiet window by hand.
 95    grade_requests_used: int = 0
 96    #: The ceiling on that. ⚠️ **``0`` is unlimited**, the reading every
 97    #: other quota on this platform takes — so a client must compare
 98    #: against it rather than treating it as "none left", which is the
 99    #: reading that greys out the button on the deployments that ration
100    #: nothing.
101    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):
138class ReportVersionsResponse(CtfyModel):
139    """Every version a team has saved, newest first.
140
141    ⚠️ Carries the bodies. That is right for one team's own history and
142    for an organiser reading one team, and wrong for a competition-wide
143    roster — which is why :class:`ReportRosterRow` exists rather than
144    this being reused there.
145    """
146
147    competition_id: str = ""
148    team_id: str = ""
149    challenge_id: str = ""
150    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 RepriceInfo(ctfy.core.models.CtfyModel):
1733class RepriceInfo(CtfyModel):
1734    """One organiser re-price of a frozen challenge (#2731 PR B), as anybody
1735    who can see the competition reads it: when and why.
1736
1737    ⚠️ **No challenge id here.** A re-price can land on a challenge not yet
1738    released to the field, and naming it would publish its existence; the
1739    organisers' copy (:class:`AdminRepriceInfo`) says which, and an
1740    organiser who wants players to know writes it into the reason.
1741    """
1742
1743    id: str
1744    repriced_at: AwareDatetime
1745    reason: str = ""

One organiser re-price of a frozen challenge (#2731 PR B), as anybody who can see the competition reads it: when and why.

⚠️ No challenge id here. A re-price can land on a challenge not yet released to the field, and naming it would publish its existence; the organisers' copy (AdminRepriceInfo) says which, and an organiser who wants players to know writes it into the reason.

id: str = PydanticUndefined
repriced_at: pydantic.types.AwareDatetime = PydanticUndefined
reason: str = ''
class RepriceRequest(ctfy.core.models.CtfyModel):
1812class RepriceRequest(CtfyModel):
1813    """Re-read one frozen challenge's price inputs from the catalog. The
1814    reason is required: it is recorded, and shown on the board."""
1815
1816    reason: str = Field(max_length=2000)

Re-read one frozen challenge's price inputs from the catalog. The reason is required: it is recorded, and shown on the board.

reason: str = PydanticUndefined
class RestoreResult(ctfy.core.models.CtfyModel):
105class RestoreResult(CtfyModel):
106    restored: int

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.

restored: int = PydanticUndefined
class RevealedAnswer(ctfy.core.models.CtfyModel):
453class RevealedAnswer(CtfyModel):
454    """One minted answer, as an organiser is shown it."""
455
456    question_id: str
457    value: str

One minted answer, as an organiser is shown it.

question_id: str = PydanticUndefined
value: str = PydanticUndefined
class ReviewDecisionRequest(ctfy.core.models.CtfyModel):
1038class ReviewDecisionRequest(CtfyModel):
1039    """Body for the organiser's approve / reject on one team."""
1040
1041    status: Literal["approved", "rejected", "pending"]
1042    # Mandatory on rejection: a rejected team is told why, and "no reason
1043    # given" turns into an organiser support ticket every time.
1044    note: str = Field(default="", max_length=2000)
1045    #: The ``review_token`` of the row the reviewer was reading (#2521). The
1046    #: browser always sends it; ``""`` is an API caller with no view to
1047    #: protect, ruled on unconditionally as before.
1048    review_token: str = Field(default="", max_length=512)
1049    #: What the rejection asks each member to do (#2527); one to-do per item.
1050    #: Only with ``status == "rejected"``. Empty is the old behaviour: any edit
1051    #: by the team sends it back to the queue.
1052    items: list[ReviewTodoItemRequest] = Field(default_factory=list, max_length=50)
1053    #: The supplement deadline (#2767): when it passes with a to-do still open,
1054    #: the team goes back to ``pending``, flagged ``overdue``. ``None`` = no
1055    #: deadline. Only on a rejection with ``items``, and in the future.
1056    todo_due_at: datetime | None = None
1057
1058    @field_validator("note")
1059    @classmethod
1060    def _note_required_on_reject(cls, v: str, info) -> str:
1061        if info.data.get("status") == "rejected" and not v.strip():
1062            raise ValueError("a note explaining the rejection is required")
1063        return v

Body for the organiser's approve / reject on one team.

status: Literal['approved', 'rejected', 'pending'] = PydanticUndefined
note: str = ''
review_token: str = ''
items: list[ReviewTodoItemRequest] = PydanticUndefined
todo_due_at: datetime.datetime | None = None
class ReviewFieldDelta(ctfy.core.models.CtfyModel):
769class ReviewFieldDelta(CtfyModel):
770    """One answer as the verdict saw it and as it is now (#2524).
771
772    ``old`` / ``new`` are plain text a person typed, ``None`` for an empty
773    answer. ``long_text`` marks free text the desk diffs line by line.
774    """
775
776    subject_user_id: str | None = None
777    subject_name: str = ""
778    field: str
779    old: str | None = None
780    new: str | None = None
781    at: datetime
782    actor_name: str = ""
783    actor_kind: RegistrationRevisionActorKind
784    long_text: bool = False

One answer as the verdict saw it and as it is now (#2524).

old / new are plain text a person typed, None for an empty answer. long_text marks free text the desk diffs line by line.

subject_user_id: str | None = None
subject_name: str = ''
field: str = PydanticUndefined
old: str | None = None
new: str | None = None
at: datetime.datetime = PydanticUndefined
actor_name: str = ''
actor_kind: Literal['entrant', 'captain', 'organiser', 'system'] = PydanticUndefined
long_text: bool = False
class ReviewPresence(ctfy.core.models.CtfyModel):
752class ReviewPresence(CtfyModel):
753    """The other reviewers of this competition viewing a team, live within the
754    presence TTL; the caller's own presence is never in it."""
755
756    competition_id: str
757    viewers: list[ReviewViewer] = Field(default_factory=list)

The other reviewers of this competition viewing a team, live within the presence TTL; the caller's own presence is never in it.

competition_id: str = PydanticUndefined
viewers: list[ReviewViewer] = PydanticUndefined
class ReviewPresenceBeat(ctfy.core.models.CtfyModel):
735class ReviewPresenceBeat(CtfyModel):
736    """``POST /admin/competitions/{id}/registration-review/presence`` (#2536):
737    the team this reviewer has open on the desk; ``""`` = none."""
738
739    team_id: str = Field(default="", max_length=200)

POST /admin/competitions/{id}/registration-review/presence (#2536): the team this reviewer has open on the desk; "" = none.

team_id: str = ''
class ReviewStaleDetail(ctfy.core.models.CtfyModel):
1066class ReviewStaleDetail(CtfyModel):
1067    """The ``detail`` of ``409 review_stale`` (#2521): what moved since the read.
1068
1069    ``changes`` is empty only for a token the server could not read. With
1070    ``verdict`` in it, the current verdict and who gave it say "X handled this
1071    team N minutes ago". No dossier content: only the facts the token names.
1072    """
1073
1074    message: str
1075    changes: list[ReviewChange] = Field(default_factory=list)
1076    review_status: RegistrationReviewStatus = "pending"
1077    reviewed_by_name: str = ""
1078    reviewed_at: datetime | None = None

The detail of 409 review_stale (#2521): what moved since the read.

changes is empty only for a token the server could not read. With verdict in it, the current verdict and who gave it say "X handled this team N minutes ago". No dossier content: only the facts the token names.

message: str = PydanticUndefined
changes: list[typing.Literal['verdict', 'reopened', 'member_edit', 'team_edit', 'members']] = PydanticUndefined
review_status: Literal['pending', 'approved', 'rejected'] = 'pending'
reviewed_by_name: str = ''
reviewed_at: datetime.datetime | None = None
class ReviewStaleError(ctfy.core.models.CtfyModel):
1081class ReviewStaleError(CtfyModel):
1082    """The whole ``409 review_stale`` body, for the OpenAPI schema."""
1083
1084    code: Literal["review_stale"] = "review_stale"
1085    detail: ReviewStaleDetail
1086    timestamp: datetime | None = None

The whole 409 review_stale body, for the OpenAPI schema.

code: Literal['review_stale'] = 'review_stale'
detail: ReviewStaleDetail = PydanticUndefined
timestamp: datetime.datetime | None = None
class ReviewTimeline(ctfy.core.models.CtfyModel):
852class ReviewTimeline(CtfyModel):
853    """``GET /admin/competitions/{id}/teams/{id}/review-timeline`` (#2524).
854
855    Values before and after are personal data, so every read is audited
856    (``registration_pii_read``, ``action="review_timeline"``).
857    """
858
859    competition_id: str
860    team_id: str
861    last_verdict: ReviewVerdictInfo | None = None
862    changes_since_verdict: list[ReviewFieldDelta] = Field(default_factory=list)
863    #: Oldest first.
864    events: list[ReviewTimelineEvent] = Field(default_factory=list)
865    #: The team's reject to-dos as they stand, oldest first (#2767 PR 4).
866    todos: list[ReviewTodoRow] = Field(default_factory=list)
867    #: ISO 8601: from when every edit's values are on record. Earlier edits
868    #: were made before the history kept values (#2516).
869    history_complete_since: str = ""

GET /admin/competitions/{id}/teams/{id}/review-timeline (#2524).

Values before and after are personal data, so every read is audited (registration_pii_read, action="review_timeline").

competition_id: str = PydanticUndefined
team_id: str = PydanticUndefined
last_verdict: ReviewVerdictInfo | None = None
changes_since_verdict: list[ReviewFieldDelta] = PydanticUndefined
events: list[ReviewTimelineEvent] = PydanticUndefined
todos: list[ReviewTodoRow] = PydanticUndefined
history_complete_since: str = ''
class ReviewTimelineChange(ctfy.core.models.CtfyModel):
787class ReviewTimelineChange(CtfyModel):
788    """One field of an edit on the timeline, values in full (#2524)."""
789
790    subject_name: str = ""
791    field: str
792    old: str | None = None
793    new: str | None = None
794    long_text: bool = False

One field of an edit on the timeline, values in full (#2524).

subject_name: str = ''
field: str = PydanticUndefined
old: str | None = None
new: str | None = None
long_text: bool = False
class ReviewTimelineEvent(ctfy.core.models.CtfyModel):
825class ReviewTimelineEvent(CtfyModel):
826    """One entry of a team's review timeline, for the organiser (#2524).
827
828    ``kind`` is a closed set the desk localises; ``reason`` is the verdict's
829    note (verdicts only); ``changes`` are an edit's fields with their values;
830    ``subject_name`` is the entrant the event concerns (an edit to their
831    dossier, a join, a leave), absent for the team.
832    """
833
834    id: str
835    at: datetime
836    kind: TimelineEventKind
837    actor_name: str = ""
838    actor_kind: RegistrationRevisionActorKind
839    subject_name: str | None = None
840    #: The entrant the event concerns, by id (#2767 PR 4): what the member
841    #: detail page narrows the team's records by. ``None`` for the team.
842    subject_user_id: str | None = None
843    reason: str | None = None
844    changes: list[ReviewTimelineChange] | None = None
845    todo: ReviewTimelineTodo | None = None
846    #: A ``conditions_lapsed`` event's unmet reasons (#2767); ``None`` otherwise.
847    conditions: list[ConditionReason] | None = None
848    #: An ``exclusion_conflict`` event's crossing (#2882); ``None`` otherwise.
849    exclusion_conflict: ExclusionConflictInfo | None = None

One entry of a team's review timeline, for the organiser (#2524).

kind is a closed set the desk localises; reason is the verdict's note (verdicts only); changes are an edit's fields with their values; subject_name is the entrant the event concerns (an edit to their dossier, a join, a leave), absent for the team.

id: str = PydanticUndefined
at: datetime.datetime = PydanticUndefined
kind: Literal['submitted', 'verdict_approved', 'verdict_rejected', 'entrant_edit', 'team_edit', 'organiser_edit', 'member_joined', 'member_left', 'resubmitted', 'new_member_reopened', 'key_field_reopened', 'todo_created', 'todo_completed', 'conditions_lapsed', 'overdue_reopened', 'exclusion_conflict'] = PydanticUndefined
actor_name: str = ''
actor_kind: Literal['entrant', 'captain', 'organiser', 'system'] = PydanticUndefined
subject_name: str | None = None
subject_user_id: str | None = None
reason: str | None = None
changes: list[ReviewTimelineChange] | None = None
todo: ReviewTimelineTodo | None = None
conditions: list[typing.Literal['', 'proof_missing', 'proof_unverified', 'role_not_accepted', 'role_unknown', 'institution_not_listed', 'institution_mixed', 'team_too_small', 'team_too_large', 'registration_missing', 'registration_incomplete', 'blocked_by_proof', 'blocked_by_institution', 'blocked_by_role']] | None = None
exclusion_conflict: ExclusionConflictInfo | None = None
class ReviewTimelineTodo(ctfy.core.models.CtfyModel):
797class ReviewTimelineTodo(CtfyModel):
798    """A reject to-do on the timeline (#2527 writes these).
799
800    ``item`` is the field key for a field item (``phone``,
801    ``team.description``), else the item kind (``verify_school_email`` /
802    ``add_member``). ``subject_name`` is ``""`` for a squad-level item.
803    """
804
805    id: str
806    subject_name: str = ""
807    item: str = ""

A reject to-do on the timeline (#2527 writes these).

item is the field key for a field item (phone, team.description), else the item kind (verify_school_email / add_member). subject_name is "" for a squad-level item.

id: str = PydanticUndefined
subject_name: str = ''
item: str = ''
class ReviewTodoItemRequest(ctfy.core.models.CtfyModel):
1023class ReviewTodoItemRequest(CtfyModel):
1024    """One thing a rejection asks somebody to do (#2527).
1025
1026    ``subject_user_id`` ``None`` is a squad-level item, the captain's.
1027    ``field`` is the registration field key for ``item == "field"`` (a dossier
1028    field such as ``"phone"``, or ``"team.description"`` for the squad's own);
1029    empty otherwise. ``note`` is an optional per-item hint shown beside it.
1030    """
1031
1032    subject_user_id: str | None = None
1033    item: RegistrationTodoItem
1034    field: str = Field(default="", max_length=80)
1035    note: str = Field(default="", max_length=500)

One thing a rejection asks somebody to do (#2527).

subject_user_id None is a squad-level item, the captain's. field is the registration field key for item == "field" (a dossier field such as "phone", or "team.description" for the squad's own); empty otherwise. note is an optional per-item hint shown beside it.

subject_user_id: str | None = None
item: Literal['field', 'verify_school_email', 'add_member'] = PydanticUndefined
field: str = ''
note: str = ''
class ReviewTodoRow(ctfy.core.models.CtfyModel):
810class ReviewTodoRow(CtfyModel):
811    """One reject to-do as it stands now (#2767 PR 4): the member detail
812    page's "this person's to-dos and deadline". ``user_id`` ``""`` is a
813    squad-level item, the captain's; ``due_at`` ``None`` is no deadline."""
814
815    id: str
816    user_id: str = ""
817    item: str = ""
818    note: str = ""
819    status: RegistrationTodoStatus = "open"
820    created_at: datetime | None = None
821    due_at: datetime | None = None
822    done_at: datetime | None = None

One reject to-do as it stands now (#2767 PR 4): the member detail page's "this person's to-dos and deadline". user_id "" is a squad-level item, the captain's; due_at None is no deadline.

id: str = PydanticUndefined
user_id: str = ''
item: str = ''
note: str = ''
status: Literal['open', 'done', 'cancelled'] = 'open'
created_at: datetime.datetime | None = None
due_at: datetime.datetime | None = None
done_at: datetime.datetime | None = None
class ReviewVerdictInfo(ctfy.core.models.CtfyModel):
760class ReviewVerdictInfo(CtfyModel):
761    """The newest ``approved`` / ``rejected`` ruling on a team (#2524)."""
762
763    status: Literal["approved", "rejected"]
764    note: str = ""
765    reviewer_name: str = ""
766    at: datetime

The newest approved / rejected ruling on a team (#2524).

status: Literal['approved', 'rejected'] = PydanticUndefined
note: str = ''
reviewer_name: str = ''
at: datetime.datetime = PydanticUndefined
class ReviewViewer(ctfy.core.models.CtfyModel):
742class ReviewViewer(CtfyModel):
743    """Another reviewer viewing a team right now. A hint, not a lock."""
744
745    team_id: str
746    user_id: str
747    name: str = ""
748    #: When they opened this team.
749    since: datetime

Another reviewer viewing a team right now. A hint, not a lock.

team_id: str = PydanticUndefined
user_id: str = PydanticUndefined
name: str = ''
since: datetime.datetime = PydanticUndefined
class ReviewerVerdictCount(ctfy.core.models.CtfyModel):
938class ReviewerVerdictCount(CtfyModel):
939    """One reviewer's approvals and rejections in the window (#2540)."""
940
941    reviewer_id: str
942    reviewer_name: str
943    approved: int = 0
944    rejected: int = 0

One reviewer's approvals and rejections in the window (#2540).

reviewer_id: str = PydanticUndefined
reviewer_name: str = PydanticUndefined
approved: int = 0
rejected: int = 0
class RiskActionRow(ctfy.core.models.CtfyModel):
57class RiskActionRow(CtfyModel):
58    """What an organiser did about a team."""
59
60    id: str
61    competition_id: str
62    team_id: str
63    team_name: str = ""
64    user_id: str = ""
65    case_id: str = ""
66    kind: RiskActionKind
67    reason: str = ""
68    actor_id: str = ""
69    actor_display_name: str = ""
70    created_at: datetime | None = None
71    #: Facts the team may be shown about *what* was done. Never an address.
72    detail: dict[str, Any] = Field(default_factory=dict)
73    #: Whether the team was told. ``False`` = a silent action.
74    team_notified: bool = True

What an organiser did about a team.

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
team_id: str = PydanticUndefined
team_name: str = ''
user_id: str = ''
case_id: str = ''
kind: Literal['warning', 'void_solves', 'disqualify', 'ban'] = PydanticUndefined
reason: str = ''
actor_id: str = ''
actor_display_name: str = ''
created_at: datetime.datetime | None = None
detail: dict[str, typing.Any] = PydanticUndefined
team_notified: bool = True
class RiskAppealList(ctfy.core.models.CtfyModel):
125class RiskAppealList(CtfyModel):
126    """The appeals queue: ``counts`` always names both states, zeros included."""
127
128    items: list[RiskAppealRow] = Field(default_factory=list)
129    counts: dict[str, int] = Field(default_factory=dict)

The appeals queue: counts always names both states, zeros included.

items: list[RiskAppealRow] = PydanticUndefined
counts: dict[str, int] = PydanticUndefined
class RiskAppealRow(ctfy.core.models.CtfyModel):
118class RiskAppealRow(CtfyModel):
119    """One appeal in the organiser's 待复核 list, with the action it is about."""
120
121    appeal: RiskAppealView
122    action: RiskActionRow

One appeal in the organiser's 待复核 list, with the action it is about.

appeal: RiskAppealView = PydanticUndefined
action: RiskActionRow = PydanticUndefined
class RiskAppealView(ctfy.core.models.CtfyModel):
77class RiskAppealView(CtfyModel):
78    """An appeal as the team and the organisers read it."""
79
80    id: str
81    action_id: str
82    body: str
83    created_at: datetime | None = None
84    supplement: str = ""
85    supplemented_at: datetime | None = None
86    reply: str = ""
87    replied_at: datetime | None = None
88    replied_by_display_name: str = ""
89    #: Is the organisers' answer still owed? Composed server-side so a client
90    #: never compares two instants itself.
91    pending: bool = True

An appeal as the team and the organisers read it.

id: str = PydanticUndefined
action_id: str = PydanticUndefined
body: str = PydanticUndefined
created_at: datetime.datetime | None = None
supplement: str = ''
supplemented_at: datetime.datetime | None = None
reply: str = ''
replied_at: datetime.datetime | None = None
replied_by_display_name: str = ''
pending: bool = True
class RiskCaseDetail(ctfy.core.models.CtfyModel):
132class RiskCaseDetail(CtfyModel):
133    """A case and everything it rests on."""
134
135    case: RiskCaseRow
136    evidence: list[RiskEvidenceRow] = Field(default_factory=list)
137    #: One per timing clue, in the case's own evidence order.
138    timelines: list[RiskTimeline] = Field(default_factory=list)
139    #: ``None`` when the case holds no shared-device or shared-IP clue.
140    device_graph: RiskDeviceGraph | None = None

A case and everything it rests on.

case: RiskCaseRow = PydanticUndefined
evidence: list[RiskEvidenceRow] = PydanticUndefined
timelines: list[RiskTimeline] = PydanticUndefined
device_graph: RiskDeviceGraph | None = None
class RiskCaseList(ctfy.core.models.CtfyModel):
55class RiskCaseList(CtfyModel):
56    """The queue: a page of cases and the count of every status.
57
58    ⚠️ ``counts`` names every status, zeros included -- the tabs' numbers must
59    not depend on which page was fetched."""
60
61    items: list[RiskCaseRow] = Field(default_factory=list)
62    total: int = 0
63    counts: dict[str, int] = Field(default_factory=dict)
64    offset: int = 0
65    limit: int = 50

The queue: a page of cases and the count of every status.

⚠️ counts names every status, zeros included -- the tabs' numbers must not depend on which page was fetched.

items: list[RiskCaseRow] = PydanticUndefined
total: int = 0
counts: dict[str, int] = PydanticUndefined
offset: int = 0
limit: int = 50
class RiskCaseRow(ctfy.core.models.CtfyModel):
35class RiskCaseRow(CtfyModel):
36    """One case in the queue."""
37
38    id: str
39    competition_id: str
40    status: RiskCaseStatus = "open"
41    #: 1 (a lead) to 3 (near-proof): the strongest clue the case holds, never a sum.
42    strength: int = 1
43    teams: list[RiskTeamRef] = Field(default_factory=list)
44    #: Which kinds of clue it holds, strongest first -- the queue's "类型" column.
45    kinds: list[RiskEvidenceKind] = Field(default_factory=list)
46    evidence_count: int = 0
47    created_at: datetime | None = None
48    updated_at: datetime | None = None
49    closed_by: str = ""
50    closed_by_display_name: str = ""
51    closed_at: datetime | None = None
52    closed_note: str = ""

One case in the queue.

id: str = PydanticUndefined
competition_id: str = PydanticUndefined
status: Literal['open', 'dismissed', 'actioned'] = 'open'
strength: int = 1
teams: list[RiskTeamRef] = PydanticUndefined
kinds: list[typing.Literal['foreign_flag', 'timing_similarity', 'shared_device', 'shared_ip', 'fast_solve', 'agent_multi_account']] = PydanticUndefined
evidence_count: int = 0
created_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
closed_by: str = ''
closed_by_display_name: str = ''
closed_at: datetime.datetime | None = None
closed_note: str = ''
class RiskDeviceAccount(ctfy.core.models.CtfyModel):
107class RiskDeviceAccount(CtfyModel):
108    """An account in the relationship picture. ``ordinal`` is its position on its
109    team (1, 2, ...) -- never a name, an id or an address."""
110
111    ref: str
112    team_id: str
113    ordinal: int

An account in the relationship picture. ordinal is its position on its team (1, 2, ...) -- never a name, an id or an address.

ref: str = PydanticUndefined
team_id: str = PydanticUndefined
ordinal: int = PydanticUndefined
class RiskDeviceGraph(ctfy.core.models.CtfyModel):
127class RiskDeviceGraph(CtfyModel):
128    accounts: list[RiskDeviceAccount] = Field(default_factory=list)
129    links: list[RiskDeviceLink] = 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.

accounts: list[RiskDeviceAccount] = PydanticUndefined
class RiskEvidenceRow(ctfy.core.models.CtfyModel):
68class RiskEvidenceRow(CtfyModel):
69    """One clue inside a case."""
70
71    id: str
72    kind: RiskEvidenceKind
73    strength: int = 1
74    teams: list[RiskTeamRef] = Field(default_factory=list)
75    #: Numbers for the reader (counts, minutes, challenge ids). Never an
76    #: address: a shared value is in ``values``, never here.
77    detail: dict[str, Any] = Field(default_factory=dict)
78    #: A ``shared_device`` / ``shared_ip`` clue's shared fingerprints or
79    #: addresses, in full (the read is audited), each once, sorted, at most
80    #: ``MAX_CLUE_VALUES``. ``[]`` for every other kind, for a clue recorded
81    #: before values were kept, and for one whose values were erased.
82    values: list[str] = Field(default_factory=list)
83    created_at: datetime | None = None
84    updated_at: datetime | None = None

One clue inside a case.

id: str = PydanticUndefined
kind: Literal['foreign_flag', 'timing_similarity', 'shared_device', 'shared_ip', 'fast_solve', 'agent_multi_account'] = PydanticUndefined
strength: int = 1
teams: list[RiskTeamRef] = PydanticUndefined
detail: dict[str, typing.Any] = PydanticUndefined
values: list[str] = PydanticUndefined
created_at: datetime.datetime | None = None
updated_at: datetime.datetime | None = None
class RiskNotice(ctfy.core.models.CtfyModel):
 94class RiskNotice(CtfyModel):
 95    """One thing done to the caller's team, and what they may do about it.
 96
 97    Drawn at the top of the competition page for the whole team (the design's
 98    panel 4). ``can_appeal`` / ``can_supplement`` are the server's verdict from
 99    ``core.risk_appeals.appeal_access`` -- the same function the mutating route
100    asks -- so a button is never drawn that the route would refuse."""
101
102    action_id: str
103    kind: RiskActionKind
104    reason: str = ""
105    created_at: datetime | None = None
106    detail: dict[str, Any] = Field(default_factory=dict)
107    #: The last instant an appeal may be filed: seven days after the action or
108    #: the report lock, whichever is earlier.
109    appeal_deadline: datetime | None = None
110    is_captain: bool = False
111    can_appeal: bool = False
112    can_supplement: bool = False
113    #: Why the next step is closed (``""`` when something is open).
114    refusal: AppealRefusal = ""
115    appeal: RiskAppealView | None = None

One thing done to the caller's team, and what they may do about it.

Drawn at the top of the competition page for the whole team (the design's panel 4). can_appeal / can_supplement are the server's verdict from core.risk_appeals.appeal_access -- the same function the mutating route asks -- so a button is never drawn that the route would refuse.

action_id: str = PydanticUndefined
kind: Literal['warning', 'void_solves', 'disqualify', 'ban'] = PydanticUndefined
reason: str = ''
created_at: datetime.datetime | None = None
detail: dict[str, typing.Any] = PydanticUndefined
appeal_deadline: datetime.datetime | None = None
is_captain: bool = False
can_appeal: bool = False
can_supplement: bool = False
refusal: Literal['', 'not_captain', 'window_closed', 'already_filed', 'supplement_used', 'no_appeal'] = ''
appeal: RiskAppealView | None = None
class RiskSettings(ctfy.core.models.CtfyModel):
143class RiskSettings(CtfyModel):
144    """Per-event risk-control switches."""
145
146    #: Whether the scan looks at this event (a new event is on).
147    enabled: bool

Per-event risk-control switches.

enabled: bool = PydanticUndefined
class RiskTeamRef(ctfy.core.models.CtfyModel):
27class RiskTeamRef(CtfyModel):
28    """A team a case is about. ``name`` is ``""`` once the team has gone."""
29
30    id: str
31    name: str = ""
32    disqualified: bool = False

A team a case is about. name is "" once the team has gone.

id: str = PydanticUndefined
name: str = ''
disqualified: bool = False
class RiskTimeline(ctfy.core.models.CtfyModel):
 97class RiskTimeline(CtfyModel):
 98    """The picture behind one ``timing_similarity`` clue, from the teams' real solves."""
 99
100    evidence_id: str
101    leader: RiskTeamRef
102    follower: RiskTeamRef
103    window_s: float
104    points: list[RiskTimingPoint] = Field(default_factory=list)

The picture behind one timing_similarity clue, from the teams' real solves.

evidence_id: str = PydanticUndefined
leader: RiskTeamRef = PydanticUndefined
follower: RiskTeamRef = PydanticUndefined
window_s: float = PydanticUndefined
points: list[RiskTimingPoint] = PydanticUndefined
class RiskTimingPoint(ctfy.core.models.CtfyModel):
87class RiskTimingPoint(CtfyModel):
88    """One challenge both teams of a timing clue solved."""
89
90    challenge_id: str
91    leader_at: datetime
92    follower_at: datetime
93    #: The follower came within the window *after* the leader -- the detector's own test.
94    following: bool

One challenge both teams of a timing clue solved.

challenge_id: str = PydanticUndefined
leader_at: datetime.datetime = PydanticUndefined
follower_at: datetime.datetime = PydanticUndefined
following: bool = PydanticUndefined
class RunTranscriptRequest(ctfy.core.models.CtfyModel):
2547class RunTranscriptRequest(CtfyModel):
2548    """Publish this run's agent's full conversation, or take it down.
2549
2550    ``RunWallRequest``'s reasoning exactly — required, no default,
2551    because *publish* asserts consent and *withdraw* undoes it and there
2552    is no safe third state. A separate request from the wall one because
2553    it is a separate, stronger consent.
2554    """
2555
2556    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):
2533class RunWallRequest(CtfyModel):
2534    """Put this run's agent on the event's wall, or take it off.
2535
2536    ⚠️ **Required, with no default.** A body that omitted it would have
2537    to mean one of the two, and *publish* is a claim about somebody's
2538    consent while *withdraw* silently undoes a decision they made — so
2539    neither is safe to assume, and there is nothing a third state could
2540    say. ``TokenScopeUpdate``'s three-valued fields exist because a
2541    PATCH has a *leave alone*; this is one switch and a POST.
2542    """
2543
2544    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):
2244class RunWriteup(CtfyModel):
2245    """What an agent said it did, in its own words.
2246
2247    ⚠️ **Rendered as plain text, never markdown and never HTML.** The
2248    same rule :class:`LlmCallBody` states, for a stronger reason: this
2249    prose was composed by an image the platform did not build, and
2250    ``CTFY-0001`` invites it to write markdown, so the temptation to
2251    render it is the whole risk.
2252
2253    ⚠️ **Not graded, and the model says so where a reader will see it.**
2254    Scoring rides the agent's own ``POST /submissions``; a write-up is
2255    telemetry beside the score, never a second grading path.
2256    """
2257
2258    run_id: str = ""
2259    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):
1270class ScheduledJobInfo(CtfyModel):
1271    """One recurring job on ``GET /admin/scheduled-jobs``: a projection of
1272    ``ScheduledJobState`` plus the code-side ``default_interval_s`` so the
1273    UI can show "(default: 30s)" next to a tuned value."""
1274
1275    name: str = ""
1276    enabled: bool = True
1277    interval_s: int = 0
1278    default_interval_s: int = 0
1279    last_run_at_ts: float = 0.0
1280    last_status: JobRunStatus = ""
1281    last_summary: str = ""
1282    last_error: str = ""
1283    last_duration_s: float = 0.0
1284    run_count: int = 0
1285    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):
1288class ScheduledJobPatch(CtfyModel):
1289    """Body of ``PATCH /admin/scheduled-jobs/{name}``. Either field may be
1290    omitted; ``interval_s`` is clamped to ``[5, 86400]`` by the route."""
1291
1292    enabled: bool | None = None
1293    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 SchoolIdentity(ctfy.core.models.CtfyModel):
64class SchoolIdentity(CtfyModel):
65    """Whether somebody has proved a school, and by which proof (#2293).
66
67    ``via == ""`` is *not verified*, never "no school": the person may well be
68    at one and simply has not linked CARSI or proved a school address. Produced
69    by ``core.eligibility.verdict_for`` against the institution catalogue, the
70    same function the registration door asks (#2282).
71    """
72
73    via: EligibilityVia = ""
74    #: The catalogue domain the proof is at; ``""`` when not verified.
75    domain: str = ""
76    #: ``{locale: name}`` from the catalogue, so the browser picks the
77    #: reader's language; empty for a domain the catalogue does not name.
78    names: dict[str, str] = Field(default_factory=dict)
79    short_name: str = ""
80    #: A *verified* address's own domain when it sits under ``.edu.cn`` and
81    #: nothing above proved a school (#2747) -- ``""`` otherwise, and always
82    #: ``""`` when ``via`` is set (the catalogue badge wins). A displayed
83    #: fact for the reviewer, not an institution and not a gate: eligibility
84    #: never reads it (`core.institutions.EDUCATION_SUFFIXES`).
85    education_domain: str = ""
86
87    @property
88    def verified(self) -> bool:
89        return self.via != ""

Whether somebody has proved a school, and by which proof (#2293).

via == "" is not verified, never "no school": the person may well be at one and simply has not linked CARSI or proved a school address. Produced by core.eligibility.verdict_for against the institution catalogue, the same function the registration door asks (#2282).

via: Literal['', 'email', 'carsi'] = ''
domain: str = ''
names: dict[str, str] = PydanticUndefined
short_name: str = ''
education_domain: str = ''
verified: bool
87    @property
88    def verified(self) -> bool:
89        return self.via != ""
class ScopeCatalogEntry(ctfy.core.models.CtfyModel):
493class ScopeCatalogEntry(CtfyModel):
494    """One assignable permission category, for the token-creation UI."""
495
496    key: str
497    label: str
498    description: str
499    competition_scoped: bool
500    #: Highest level a user may grant for this category.
501    #:
502    #: ⚠️ **The wire was the loose link in a chain of three.**
503    #: ``token_scopes.ScopeCategoryInfo.max_level`` is typed
504    #: ``PermissionLevel``, this copy of it was ``str`` with the members
505    #: in a comment, and ``frontend/src/api.ts`` then declared the same
506    #: field ``PermissionLevel`` again — so the client's stricter claim
507    #: rested on a promise the middle layer had dropped. It is also the
508    #: value ``clamp_permissions`` indexes its rank table with, which is
509    #: where an unlisted level becomes a ``KeyError``.
510    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):
1458class ScoreBucket(CtfyModel):
1459    """One bucket of the scoreboard score-distribution histogram."""
1460
1461    label: str
1462    count: int = 0

One bucket of the scoreboard score-distribution histogram.

label: str = PydanticUndefined
count: int = 0
class ScoreHistoryPoint(ctfy.core.models.CtfyModel):
1348class ScoreHistoryPoint(CtfyModel):
1349    """One sample on a team's score/rank-over-time curve.
1350
1351    Both metrics travel together so the frontend can toggle
1352    Score ⇄ Rank without a refetch.
1353    """
1354
1355    ts: float = 0.0  # Unix seconds (solve time of the driving event)
1356    score: int = 0  # Cumulative flags captured up to ``ts``
1357    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):
1360class ScoreHistorySeries(CtfyModel):
1361    team_id: str
1362    team_name: str
1363    points: list[ScoreHistoryPoint] = Field(default_factory=list)
1364    #: See :attr:`ScoreboardEntry.anonymous`. The curve keeps its shape
1365    #: under the alias, which is what the alias being stable buys.
1366    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):
1201class ScoreboardEntry(CtfyModel):
1202    rank: int = 0
1203    team_id: str
1204    team_name: str
1205    # Challenges where the team captured every declared flag. Preserved
1206    # for parity with the legacy single-flag scoreboard.
1207    solved: int = 0
1208    # Total individual flags captured across all challenges. Primary
1209    # scoreboard metric now that multi-flag challenges exist — rewards
1210    # partial progress (e.g. foothold without root).
1211    flags_solved: int = 0
1212    # The number this board is ranked by, as decided by the
1213    # competition's scoring rule (``ctfy/server/scoring/``). Under the
1214    # default ``flag_count`` rule it equals ``flags_solved``; under a
1215    # points rule it is the point total. Kept separate from
1216    # ``flags_solved`` so the UI can show both "how much progress" and
1217    # "how many points" without knowing which rule produced the row.
1218    score: int = 0
1219    attempts: int = 0
1220    #: How many hint tiers this team opened in the competition (#2101). A
1221    #: board breaks a tie on the fewer, before the time of the last solve
1222    #: — under ``record`` that is the whole cost of a hint, under the other
1223    #: modes it is a tie-break beside the cap or the deduction. ``0`` for
1224    #: every team on a competition with no hints, so no board moves.
1225    hints_used: int = 0
1226    last_solve_at: datetime | None = None
1227    # Most recent activity of any kind. Distinct from ``last_solve_at`` —
1228    # a team may be actively attempting without a solve, and the UI
1229    # wants to show that they're not idle.
1230    last_active_at: datetime | None = None
1231    # OAuth provider avatar URL for the team's earliest identity that
1232    # supplies one. Empty for password-only teams; the frontend falls
1233    # back to a generated initials avatar in that case.
1234    # Whether a person or an agent is behind this entry. Both compete on
1235    # the same board — a user enters, and a user's agent enters — so a
1236    # board that does not say which is which is one whose ranking cannot
1237    # be read.
1238    #
1239    # ⚠️ A display fact, never a ranking one. No rule scores on it: an
1240    # agent and a human are ranked by what they solved, and separating
1241    # them is the reader's choice to make from a labelled board.
1242    entrant_kind: EntrantKind = "human"
1243    # Whose agent this is, when it is somebody's. ``None`` for a person
1244    # **and** for the platform's own eval entrant — see
1245    # :class:`AgentEntrantInfo`.
1246    #
1247    # ⚠️ Attached after the scoring rule has produced the row, never
1248    # carried through :class:`TeamFacts`. A rule reads only the facts it
1249    # names, so keeping this out of them is what makes "no rule can rank
1250    # on who built the agent" structural rather than a convention four
1251    # rule implementations have to keep.
1252    agent: AgentEntrantInfo | None = None
1253    avatar_url: str = ""
1254    #: The institution **this squad** publishes, or ``None`` — see
1255    #: :attr:`ctfy.server.models.TeamInfo.affiliation`, which is the
1256    #: same fact about the same subject.
1257    #:
1258    #: ⚠️ **A display fact, never a ranking one**, exactly like
1259    #: ``entrant_kind`` and ``agent``: it is attached after the scoring
1260    #: rule has produced the row and never carried through
1261    #: :class:`TeamFacts`, so no rule can rank on where a team studies.
1262    #:
1263    #: ⚠️ **Cleared by ``_board_fields``**, with the avatar and the
1264    #: agent attribution: an institution beside an alias is stronger
1265    #: identity than the name it replaced.
1266    affiliation: AffiliationInfo | None = None
1267    # The AWD+ split of ``score``. Zero on every other format, where the
1268    # board renders one number and these columns stay hidden.
1269    #
1270    # Both are carried even though ``attack + defence == score``, because
1271    # the point of the split *is* the breakdown: a single total never
1272    # explains why a team that solved less is ranked higher. Deriving one
1273    # from the other in the client would put the format's arithmetic in
1274    # two places.
1275    # ⚠️ **Whether the identity on this row is withheld, never whether
1276    # the entrant asked for it.** The client needs to know to render the
1277    # localised word beside the alias and to stop linking; it must not be
1278    # told anything about who is behind it. Defaults ``False``, which is
1279    # what every producer that predates the field means and what an
1280    # older server's rows read as — the safe direction, since a wrong
1281    # ``True`` merely labels a visible team "anonymous" while a wrong
1282    # ``False`` publishes nothing it did not already publish (the name it
1283    # would print is the alias either way).
1284    anonymous: bool = False
1285    attack_score: int = 0
1286    defence_score: int = 0
1287    # Challenges this team holds a ``fixed`` defence verdict on. A count,
1288    # not the ids — the board is a ranking, and the ids are one click
1289    # away on the team's own page.
1290    defended: int = 0
1291    # The exercise-format split of ``score``. Zero on every other format,
1292    # for the same reason and with the same shape as the AWD+ pair above.
1293    #
1294    # ⚠️ Deliberately **not** a reuse of ``attack_score`` /
1295    # ``defence_score``. The numbers happen to compose the same way and
1296    # they are different facts: reusing the AWD names would put "how well
1297    # this team wrote it up" in a field every reader has been told means
1298    # "patches that survived the exploit". A wire field is a claim about
1299    # what the number *is*.
1300    #
1301    # ``reporting_score`` is the graded half weighted onto the capability
1302    # scale, so it is what the board ranked by — the grader's own
1303    # unweighted total stays on ``ExerciseReportGradeState.points``,
1304    # which is the record an appeal argues from.
1305    capability_score: int = 0
1306    reporting_score: int = 0
1307    # The peer-authoring split of ``score``. Zero on every other
1308    # format, same shape and same reason as the two pairs above.
1309    #
1310    # ⚠️ **Its own field rather than a reuse of ``reporting_score``**,
1311    # for the reason stated there about the AWD pair: the numbers
1312    # compose the same way and they are different facts, and a wire
1313    # field is a claim about what the number *is*. "How the field fared
1314    # against the challenges you wrote" in a field readers have been
1315    # told means "how well you wrote up an engagement" is a wrong
1316    # sentence rendered from correct arithmetic.
1317    #
1318    # ⚠️ It is the **weighted** term — what the board ranked by — for
1319    # `reporting_score`'s reason: the unweighted per-challenge curve
1320    # values are not stored anywhere a dispute could argue from, so this
1321    # number has to be the one the ranking used rather than a second
1322    # rendering of it. The solving half sits on ``capability_score``,
1323    # which this format reuses verbatim.
1324    authoring_score: int = 0
1325    # The team's eligibility verdict, and **only when the viewer is
1326    # entitled to it** — a competition admin or reviewer. Everywhere else
1327    # this is ``""``.
1328    #
1329    # ``""`` means "not disclosed", never "approved". Defaulting it to
1330    # ``approved`` would have been the natural-looking choice and is the
1331    # bug: a public board would then assert that every team passed
1332    # review, which is a stronger claim than the platform is entitled to
1333    # make and is false during the review backlog.
1334    #
1335    # Not public because no other surface publishes it either — the
1336    # verdict reaches the team's own members and the organisers, and
1337    # nobody else. Putting "rejected" next to a team name on a page the
1338    # whole event reads is a different act from recording it on the
1339    # review queue.
1340    #
1341    # It never affects ``rank``: review is an eligibility marker, not a
1342    # gate, and an unapproved team plays and places exactly as it would
1343    # have. ``TestReviewIsNotAGate`` pins that; this field is what makes
1344    # the fact *visible* to the organiser without changing it.
1345    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
hints_used: 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 ScoringChangeBoardRow(ctfy.core.models.CtfyModel):
1709class ScoringChangeBoardRow(CtfyModel):
1710    """One standing as the change found it."""
1711
1712    rank: int = 0
1713    team_id: str = ""
1714    team_name: str = ""
1715    score: int = 0

One standing as the change found it.

rank: int = 0
team_id: str = ''
team_name: str = ''
score: int = 0
class ScoringChangeInfo(ctfy.core.models.CtfyModel):
1687class ScoringChangeInfo(CtfyModel):
1688    """One recorded change to a live competition's scoring (#1965 H).
1689
1690    Public to whoever can see the competition: a re-ranked board says
1691    when and why it moved, from what to what, so an entrant — or their
1692    agent polling the API — can tell a rule change from a scoring bug.
1693    Who changed it and the board it replaced are the organisers'
1694    (:class:`AdminScoringChangeInfo`).
1695    """
1696
1697    id: str
1698    changed_at: AwareDatetime
1699    reason: str = ""
1700    old_rule: ScoringRuleName | Literal[""] = ""
1701    old_params: dict[str, Any] = Field(default_factory=dict)
1702    new_rule: ScoringRuleName | Literal[""] = ""
1703    new_params: dict[str, Any] = Field(default_factory=dict)
1704    #: Which board changed (#1965 G): an extra board's slug from
1705    #: ``GET /competitions/{id}/boards``, or ``""`` for the primary.
1706    board: str = ""

One recorded change to a live competition's scoring (#1965 H).

Public to whoever can see the competition: a re-ranked board says when and why it moved, from what to what, so an entrant — or their agent polling the API — can tell a rule change from a scoring bug. Who changed it and the board it replaced are the organisers' (AdminScoringChangeInfo).

id: str = PydanticUndefined
changed_at: pydantic.types.AwareDatetime = PydanticUndefined
reason: str = ''
old_rule: Union[Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring', 'authoring'], Literal['']] = ''
old_params: dict[str, typing.Any] = PydanticUndefined
new_rule: Union[Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring', 'authoring'], Literal['']] = ''
new_params: dict[str, typing.Any] = PydanticUndefined
board: str = ''
class ScoringChangesResponse(ctfy.core.models.CtfyModel):
1819class ScoringChangesResponse(CtfyModel):
1820    """Every recorded mid-event scoring change of one competition, oldest
1821    first. Bounded by how often an organiser changes a rule, so never
1822    paged."""
1823
1824    changes: list[ScoringChangeInfo] = Field(default_factory=list)
1825    #: Every re-price of a frozen challenge (#2731 PR B), oldest first — a
1826    #: live scoring change too, so it is announced like one.
1827    reprices: list[RepriceInfo] = Field(default_factory=list)

Every recorded mid-event scoring change of one competition, oldest first. Bounded by how often an organiser changes a rule, so never paged.

changes: list[ScoringChangeInfo] = PydanticUndefined
reprices: list[RepriceInfo] = PydanticUndefined
class ScoringManifestEntryInfo(ctfy.core.models.CtfyModel):
1748class ScoringManifestEntryInfo(CtfyModel):
1749    """One challenge's frozen price inputs (#2731 PR B,
1750    ``core.scoring_manifest``): the difficulty its total is read from and
1751    the scoring flag set with each part's weight."""
1752
1753    difficulty: str = ""
1754    parts: dict[str, int] = Field(default_factory=dict)

One challenge's frozen price inputs (#2731 PR B, core.scoring_manifest): the difficulty its total is read from and the scoring flag set with each part's weight.

difficulty: str = ''
parts: dict[str, int] = PydanticUndefined
class ScoringManifestInfo(ctfy.core.models.CtfyModel):
1799class ScoringManifestInfo(CtfyModel):
1800    """A competition's frozen scoring manifest and its re-price history
1801    (``GET /admin/competitions/{id}/scoring-manifest``)."""
1802
1803    competition_id: str
1804    #: Whether anything is frozen yet: ``False`` before the start, when every
1805    #: price is read from the live catalog.
1806    frozen: bool = False
1807    rows: list[ScoringManifestRow] = Field(default_factory=list)
1808    #: Every re-price, oldest first. The whole history, never a page.
1809    reprices: list[AdminRepriceInfo] = Field(default_factory=list)

A competition's frozen scoring manifest and its re-price history (GET /admin/competitions/{id}/scoring-manifest).

competition_id: str = PydanticUndefined
frozen: bool = False
rows: list[ScoringManifestRow] = PydanticUndefined
reprices: list[AdminRepriceInfo] = PydanticUndefined
class ScoringManifestPrice(ctfy.server.models.ScoringManifestEntryInfo):
1772class ScoringManifestPrice(ScoringManifestEntryInfo):
1773    """Price inputs plus what they pay under the competition's current rule.
1774
1775    ``value`` is the card's price at the first solve (``challenge_value``):
1776    the whole challenge under ``challenge_total``, one flag under ``legacy``.
1777    ⚠️ ``None`` is "this rule prices no challenge" (``flag_count``), never 0.
1778    """
1779
1780    value: int | None = None

Price inputs plus what they pay under the competition's current rule.

value is the card's price at the first solve (challenge_value): the whole challenge under challenge_total, one flag under legacy. ⚠️ None is "this rule prices no challenge" (flag_count), never 0.

value: int | None = None
class ScoringManifestRow(ctfy.core.models.CtfyModel):
1783class ScoringManifestRow(CtfyModel):
1784    """One frozen challenge beside what the catalog says now."""
1785
1786    challenge_id: str
1787    #: The catalog's name for it; ``""`` when the catalog no longer has it.
1788    name: str = ""
1789    frozen: ScoringManifestPrice
1790    frozen_at: AwareDatetime | None = None
1791    #: What a re-price would freeze now; ``None`` when the catalog can no
1792    #: longer describe the challenge (nothing to re-price from).
1793    current: ScoringManifestPrice | None = None
1794    #: Whether ``current`` prices differently from ``frozen`` — the rows a
1795    #: re-price would change.
1796    differs: bool = False

One frozen challenge beside what the catalog says now.

challenge_id: str = PydanticUndefined
name: str = ''
frozen: ScoringManifestPrice = PydanticUndefined
frozen_at: pydantic.types.AwareDatetime | None = None
current: ScoringManifestPrice | None = None
differs: bool = False
class ScoringPreview(ctfy.core.models.CtfyModel):
836class ScoringPreview(CtfyModel):
837    """A draft scoring config priced by the board's own arithmetic.
838
839    ⚠️ **Every number is an answer ``PointsRule`` gave** (``challenge_value``
840    and ``first_blood_extra``). The browser draws these and computes none,
841    because a client-side curve would be a second implementation of
842    ``_decayed`` - the copy a reader looks at when the two disagree.
843    """
844
845    #: ``False`` where the rule does not price challenges (flag count);
846    #: every other field is then empty.
847    priced: bool = False
848    #: The reference curve's name; ``none`` for a board that does not decay.
849    curve: str = "none"
850    reference_id: str = ""
851    max_solvers: int = 0
852    table_solvers: int = 0
853    rows: list[ScoringPreviewRow] = Field(default_factory=list)
854    #: The configured curve first, then each comparison.
855    series: list[ScoringPreviewSeries] = Field(default_factory=list)
856    first_blood: list[ScoringPreviewBlood] = Field(default_factory=list)

A draft scoring config priced by the board's own arithmetic.

⚠️ Every number is an answer PointsRule gave (challenge_value and first_blood_extra). The browser draws these and computes none, because a client-side curve would be a second implementation of _decayed - the copy a reader looks at when the two disagree.

priced: bool = False
curve: str = 'none'
reference_id: str = ''
max_solvers: int = 0
table_solvers: int = 0
rows: list[ScoringPreviewRow] = PydanticUndefined
series: list[ScoringPreviewSeries] = PydanticUndefined
first_blood: list[ScoringPreviewBlood] = PydanticUndefined
class ScoringPreviewBlood(ctfy.core.models.CtfyModel):
825class ScoringPreviewBlood(CtfyModel):
826    """What one placement earns on the reference challenge."""
827
828    place: int
829    pct: int
830    #: The price the placement's team captured at.
831    value: int
832    #: The points added on top of ``value``.
833    extra: int

What one placement earns on the reference challenge.

place: int = PydanticUndefined
pct: int = PydanticUndefined
value: int = PydanticUndefined
extra: int = PydanticUndefined
class ScoringPreviewChallenge(ctfy.core.models.CtfyModel):
775class ScoringPreviewChallenge(CtfyModel):
776    """One challenge a scoring preview prices: its id and declared level.
777
778    ⚠️ Sent by the form rather than read off the corpus: the organiser is
779    pricing a set they have not saved yet (and, in the wizard, one that
780    does not exist), so the stored competition cannot be the source.
781    """
782
783    id: str = Field(min_length=1, max_length=200)
784    difficulty: str = Field(default="", max_length=40)

One challenge a scoring preview prices: its id and declared level.

⚠️ Sent by the form rather than read off the corpus: the organiser is pricing a set they have not saved yet (and, in the wizard, one that does not exist), so the stored competition cannot be the source.

id: str = PydanticUndefined
difficulty: str = ''
class ScoringPreviewRequest(ctfy.core.models.CtfyModel):
787class ScoringPreviewRequest(CtfyModel):
788    """``POST /admin/scoring/preview`` - price a draft scoring config."""
789
790    scoring_rule: ScoringRuleName | Literal[""] = ""
791    scoring_params: dict[str, Any] = Field(default_factory=dict)
792    challenges: list[ScoringPreviewChallenge] = Field(min_length=1, max_length=500)
793    #: Which challenge the curves are drawn for. Must be one of
794    #: ``challenges``; empty means the first.
795    reference_id: str = ""
796    #: How many solvers the curves run to.
797    max_solvers: int = Field(default=100, ge=2, le=500)
798    #: The solver count the per-challenge table is priced at.
799    table_solvers: int = Field(default=10, ge=1, le=500)
800    #: Other curves to draw for the reference, for comparison. Honoured
801    #: only where the rule carries a curve.
802    compare_curves: list[str] = Field(default_factory=list, max_length=10)

POST /admin/scoring/preview - price a draft scoring config.

scoring_rule: Union[Literal['flag_count', 'static_points', 'dynamic_points', 'awd', 'exercise', 'peer_authoring', 'authoring'], Literal['']] = ''
scoring_params: dict[str, typing.Any] = PydanticUndefined
challenges: list[ScoringPreviewChallenge] = PydanticUndefined
reference_id: str = ''
max_solvers: int = 100
table_solvers: int = 10
compare_curves: list[str] = PydanticUndefined
class ScoringPreviewRow(ctfy.core.models.CtfyModel):
805class ScoringPreviewRow(CtfyModel):
806    """One challenge priced under the draft config."""
807
808    id: str
809    difficulty: str = ""
810    #: The price at one solver (the first capture).
811    base_value: int
812    #: The price a mass-solved challenge settles at.
813    floor_value: int
814    #: The price at ``table_solvers`` holders.
815    value_at: int

One challenge priced under the draft config.

id: str = PydanticUndefined
difficulty: str = ''
base_value: int = PydanticUndefined
floor_value: int = PydanticUndefined
value_at: int = PydanticUndefined
class ScoringPreviewSeries(ctfy.core.models.CtfyModel):
818class ScoringPreviewSeries(CtfyModel):
819    """One curve: ``values[i]`` is the price with ``i + 1`` solvers."""
820
821    curve: str
822    values: list[int] = Field(default_factory=list)

One curve: values[i] is the price with i + 1 solvers.

curve: str = PydanticUndefined
values: list[int] = PydanticUndefined
class SearchGroup(ctfy.core.models.CtfyModel):
80class SearchGroup(CtfyModel):
81    name: SearchGroupName
82    #: At most ``limit`` rows, best match first.
83    items: list[SearchHit] = Field(default_factory=list)
84    #: Every row the caller could open that matches, not just ``items``.
85    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.

name: SearchGroupName = PydanticUndefined
items: list[SearchHit] = PydanticUndefined
total: int = 0
class SearchGroupName(enum.StrEnum):
35class SearchGroupName(StrEnum):
36    """Which section of the panel a group fills. ``exact`` is an
37    administrator's pasted id or address resolving to one object."""
38
39    EXACT = "exact"
40    COMPETITION = "competition"
41    CHALLENGE = "challenge"
42    TEAM = "team"
43    USER = "user"
44    AUTHORED = "authored"
45    NODE = "node"

Which section of the panel a group fills. exact is an administrator's pasted id or address resolving to one object.

EXACT = <SearchGroupName.EXACT: 'exact'>
COMPETITION = <SearchGroupName.COMPETITION: 'competition'>
CHALLENGE = <SearchGroupName.CHALLENGE: 'challenge'>
TEAM = <SearchGroupName.TEAM: 'team'>
USER = <SearchGroupName.USER: 'user'>
AUTHORED = <SearchGroupName.AUTHORED: 'authored'>
NODE = <SearchGroupName.NODE: 'node'>
class SearchHit(ctfy.core.models.CtfyModel):
56class SearchHit(CtfyModel):
57    """One result row."""
58
59    kind: SearchKind
60    id: str
61    #: What the row is called -- a title, a team name, an address.
62    title: str
63    #: The title per locale when this row is a competition (#2230); a
64    #: browser renders this, ``title`` is its base-locale reading.
65    title_i18n: LocalizedText = Field(default_factory=dict)
66    #: The competition the object belongs to, ``""`` when it belongs to none.
67    #: A challenge opened from here is read in this event's scope.
68    competition_id: str = ""
69    #: Challenge only.
70    category: str = ""
71    difficulty: str = ""
72    tags: list[str] = Field(default_factory=list)
73    #: Competition only: ``upcoming`` / ``running`` / ``past``.
74    phase: str = ""
75    #: Short free text a row prints under its title (an admin object's
76    #: status or owner label). Empty when there is nothing to say.
77    detail: str = ""

One result row.

kind: SearchKind = PydanticUndefined
id: str = PydanticUndefined
title: str = PydanticUndefined
title_i18n: dict[str, str] = PydanticUndefined
competition_id: str = ''
category: str = ''
difficulty: str = ''
tags: list[str] = PydanticUndefined
phase: str = ''
detail: str = ''
class SearchKind(enum.StrEnum):
24class SearchKind(StrEnum):
25    COMPETITION = "competition"
26    CHALLENGE = "challenge"
27    TEAM = "team"
28    USER = "user"
29    AUTHORED = "authored"
30    TASK = "task"
31    INSTANCE = "instance"
32    NODE = "node"
COMPETITION = <SearchKind.COMPETITION: 'competition'>
CHALLENGE = <SearchKind.CHALLENGE: 'challenge'>
TEAM = <SearchKind.TEAM: 'team'>
USER = <SearchKind.USER: 'user'>
AUTHORED = <SearchKind.AUTHORED: 'authored'>
TASK = <SearchKind.TASK: 'task'>
INSTANCE = <SearchKind.INSTANCE: 'instance'>
NODE = <SearchKind.NODE: 'node'>
class SearchResponse(ctfy.core.models.CtfyModel):
 88class SearchResponse(CtfyModel):
 89    q: str
 90    scope: SearchScope
 91    #: The competition the search was narrowed to or prioritised, ``""`` if none.
 92    competition_id: str = ""
 93    #: That competition's title, for the scope chip; ``""`` with no focus.
 94    competition_title: str = ""
 95    #: The competition's title per locale (#2230); a browser renders this,
 96    #: ``competition_title`` is its base-locale reading.
 97    competition_title_i18n: LocalizedText = Field(default_factory=dict)
 98    #: Only groups with at least one match, in display order.
 99    groups: list[SearchGroup] = Field(default_factory=list)
100    #: Set when the focused competition has not started and the caller does
101    #: not organise it: its challenges are withheld until this instant (the
102    #: start gate every challenge door asks). Says nothing about whether any
103    #: challenge exists. ``None`` otherwise.
104    withheld_until: AwareDatetime | 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.

q: str = PydanticUndefined
scope: SearchScope = PydanticUndefined
competition_id: str = ''
competition_title: str = ''
competition_title_i18n: dict[str, str] = PydanticUndefined
groups: list[SearchGroup] = PydanticUndefined
withheld_until: pydantic.types.AwareDatetime | None = None
class SearchScope(enum.StrEnum):
48class SearchScope(StrEnum):
49    #: Everywhere the caller may look. With ``competition_id`` the named
50    #: event's challenges are listed first.
51    ALL = "all"
52    #: Only the named competition's challenges and teams.
53    COMPETITION = "competition"
ALL = <SearchScope.ALL: 'all'>
COMPETITION = <SearchScope.COMPETITION: 'competition'>
class SeriesOccurrencePreview(ctfy.core.models.CtfyModel):
163class SeriesOccurrencePreview(CtfyModel):
164    """One occurrence a series *would* create, without creating it."""
165
166    occurrence_at: AwareDatetime
167    title: str = ""
168    challenge_ids: list[str] = Field(default_factory=list)
169    #: Empty when this occurrence could be filled. Otherwise why not —
170    #: the pool ran dry, or names challenges the catalog no longer has.
171    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):
174class SeriesPreview(CtfyModel):
175    """The dry run.
176
177    Its own route rather than a flag on the save, for the same reason
178    `GET /awd/plan` is: the answer is what decides whether to commit. A
179    weekly's first real feedback is otherwise a week away, and the
180    mistakes it catches — wrong weekday, wrong timezone, a pool that
181    covers three weeks of a schedule meant to run indefinitely — are all
182    invisible in the form.
183
184    ⚠️ It projects **past the lead window** on purpose. `plan_spawns`
185    answers "what should exist now", which for a freshly-saved series is
186    usually one contest; an organiser needs to see the *fourth* week to
187    know the pool runs out.
188    """
189
190    series_id: str = ""
191    occurrences: list[SeriesOccurrencePreview] = Field(default_factory=list)
192    #: Occurrences already past, reported for the same reason the job
193    #: reports them: a schedule whose anchor is in the past is a common
194    #: mistake and silently produces nothing.
195    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):
339class SetPasswordRequest(CtfyModel):
340    # Required iff the caller already has a password identity — absent on
341    # the first-time "attach password to an OAuth account" flow.
342    current_password: str = Field(default="", max_length=256)
343    # See RegisterRequest.password for the length-minimum rationale.
344    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 SharedEnvKeyRequest(ctfy.core.models.CtfyModel):
173class SharedEnvKeyRequest(CtfyModel):
174    challenge_id: str
175    group_key: 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
group_key: str = PydanticUndefined
class SharedEnvReportResult(ctfy.core.models.CtfyModel):
178class SharedEnvReportResult(CtfyModel):
179    """What a player gets back from "报告环境异常": that it was taken.
180
181    ⚠️ The only player-facing model of this feature, and deliberately empty
182    of everything else -- no count of reports, no group, no organiser state.
183    """
184
185    reported: bool = True

What a player gets back from "报告环境异常": that it was taken.

⚠️ The only player-facing model of this feature, and deliberately empty of everything else -- no count of reports, no group, no organiser state.

reported: bool = True
class SharedEnvRow(ctfy.core.models.CtfyModel):
147class SharedEnvRow(CtfyModel):
148    """One shared environment in the organiser's health table."""
149
150    challenge_id: str
151    name: str = ""
152    #: ``all`` or ``g<N>``.
153    group_key: str
154    now: SharedEnvNow = "none"
155    #: The live box, when there is one.
156    instance_id: str = ""
157    #: Last 24 hourly slots, oldest first: ``ok`` / ``recovered`` / ``down``
158    #: / ``none`` (nothing was running).
159    strip: list[str] = Field(default_factory=list)
160    #: Unresolved "this looks broken" reports from players.
161    reports: int = 0
162    restarts: int = 0
163    last_restart_at: AwareDatetime | None = None
164    #: ``health`` / ``organiser`` / ``""``.
165    last_restart_reason: str = ""

One shared environment in the organiser's health table.

challenge_id: str = PydanticUndefined
name: str = ''
group_key: str = PydanticUndefined
now: Literal['none', 'starting', 'ready', 'error'] = 'none'
instance_id: str = ''
strip: list[str] = PydanticUndefined
reports: int = 0
restarts: int = 0
last_restart_at: pydantic.types.AwareDatetime | None = None
last_restart_reason: str = ''
class SharedEnvTable(ctfy.core.models.CtfyModel):
168class SharedEnvTable(CtfyModel):
169    competition_id: str
170    rows: list[SharedEnvRow] = 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.

competition_id: str = PydanticUndefined
rows: list[SharedEnvRow] = PydanticUndefined
class ShellRecordingInfo(ctfy.core.models.CtfyModel):
617class ShellRecordingInfo(CtfyModel):
618    """One row from ``GET /admin/shell-recordings``: a per-session
619    asciinema v2 cast file the platform wrote when a super-admin
620    opened a docker-exec shell. The frontend renders a playable
621    inline player; the raw bytes are pulled via the per-session
622    download route (also super-admin gated, also audited)."""
623
624    session_id: str = ""
625    path: str = ""
626    size_bytes: int = 0
627    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 SightingRow(ctfy.core.models.CtfyModel):
150class SightingRow(CtfyModel):
151    """Where an account was seen from. **Platform administrators only.**"""
152
153    kind: SightingKind
154    value: str
155    first_seen: datetime | None = None
156    last_seen: datetime | None = None
157    seen_count: int = 1

Where an account was seen from. Platform administrators only.

kind: Literal['ip', 'fingerprint'] = PydanticUndefined
value: str = PydanticUndefined
first_seen: datetime.datetime | None = None
last_seen: datetime.datetime | None = None
seen_count: int = 1
class SightingsResponse(ctfy.core.models.CtfyModel):
160class SightingsResponse(CtfyModel):
161    """An account's sealed sightings, opened for a platform administrator.
162
163    An IP is a lead, never evidence: campus and office NAT put honest accounts
164    behind one address."""
165
166    user_id: str
167    items: list[SightingRow] = Field(default_factory=list)

An account's sealed sightings, opened for a platform administrator.

An IP is a lead, never evidence: campus and office NAT put honest accounts behind one address.

user_id: str = PydanticUndefined
items: list[SightingRow] = PydanticUndefined
class SiteMessageView(ctfy.core.models.CtfyModel):
 99class SiteMessageView(CtfyModel):
100    """One message, as the caller reads it."""
101
102    id: str
103    thread_id: str
104    side: MessageSide
105    #: ``""`` on the organiser side when the reader is a player.
106    author_user_id: str = ""
107    author_label: str = ""
108    body: str
109    created_at: datetime | None = None
110    #: The organisers asked for a mail reminder with this message.
111    emailed: bool = False
112    #: Names withheld from ``body`` (a personalised send's credentials); the
113    #: values are read through ``POST .../messages/{id}/secrets``, audited.
114    secret_names: list[str] = Field(default_factory=list)
115    #: The addressed side's readers who have / have not read this message
116    #: (team threads; organisers are never listed).
117    read_by: list[str] = Field(default_factory=list)
118    unread_by: list[str] = Field(default_factory=list)

One message, as the caller reads it.

id: str = PydanticUndefined
thread_id: str = PydanticUndefined
side: Literal['organiser', 'player'] = PydanticUndefined
author_user_id: str = ''
author_label: str = ''
body: str = PydanticUndefined
created_at: datetime.datetime | None = None
emailed: bool = False
secret_names: list[str] = PydanticUndefined
read_by: list[str] = PydanticUndefined
unread_by: list[str] = PydanticUndefined
class SittingResult(ctfy.core.models.CtfyModel):
15class SittingResult(CtfyModel):
16    """One event a person entered, at that event's own official placement.
17
18    ⚠️ **``rank`` is the competition board's own row, never derived here.**
19    It is read through the board's scoring (rule, challenge set, team
20    filter, ``board_until``) for the reader asking, so during a freeze it is
21    the placement at the freeze instant, exactly as the board shows it. A
22    sitting has no total and the list has none either: events are not
23    comparable, so nothing is added up.
24
25    ``rank`` is ``None`` whenever ``unranked`` says why; ``entrants`` is the
26    number of rows on the same board the rank was read from.
27    """
28
29    competition_id: str
30    competition_title: str
31    #: The competition's title per locale (#2230).
32    competition_title_i18n: LocalizedText = Field(default_factory=dict)
33    format: CompetitionFormat
34    #: Set when the event belongs to a series.
35    series_id: str = ""
36    starts_at: datetime | None = None
37    ends_at: datetime | None = None
38    team_name: str = ""
39    standing: ResultStanding
40    #: The instant the board is held at, while ``standing`` is ``frozen``.
41    frozen_at: datetime | None = None
42    rank: int | None = None
43    entrants: int = 0
44    #: The number the event's own rule ranks by; ``None`` with ``rank``.
45    score: float | None = None
46    unranked: ResultUnranked = ""
47    #: Entered with the identity withheld (team or person). Owner view only:
48    #: an anonymous sitting never reaches anybody else's copy of the list.
49    anonymous: bool = False
50    #: ⚠️ **Decided by the server for the owner's own copy**: True when
51    #: nobody else may see this sitting on the profile (in progress, frozen,
52    #: anonymous, disqualified, hidden event). Always False on the profile's
53    #: copy, which holds only rows others may see.
54    only_you: bool = False

One event a person entered, at that event's own official placement.

⚠️ rank is the competition board's own row, never derived here. It is read through the board's scoring (rule, challenge set, team filter, board_until) for the reader asking, so during a freeze it is the placement at the freeze instant, exactly as the board shows it. A sitting has no total and the list has none either: events are not comparable, so nothing is added up.

rank is None whenever unranked says why; entrants is the number of rows on the same board the rank was read from.

competition_id: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_title_i18n: dict[str, str] = PydanticUndefined
format: Literal['questions', 'engagement', 'practice', 'awd', 'authoring', 'custom'] = PydanticUndefined
series_id: str = ''
starts_at: datetime.datetime | None = None
ends_at: datetime.datetime | None = None
team_name: str = ''
standing: Literal['live', 'frozen', 'final'] = PydanticUndefined
frozen_at: datetime.datetime | None = None
rank: int | None = None
entrants: int = 0
score: float | None = None
unranked: Literal['', 'disqualified', 'silent', 'rounds_match', 'not_on_board'] = ''
anonymous: bool = False
only_you: bool = False
class SmokeQueueInfo(ctfy.core.models.CtfyModel):
2378class SmokeQueueInfo(CtfyModel):
2379    """Where a waiting run stands among the runs waiting (#2145).
2380
2381    ``eta_s`` is ``None`` when it cannot be told (no node to run on, or too
2382    little history) — a missing number, never a zero.
2383    """
2384
2385    position: int
2386    length: int
2387    eta_s: int | None = None

Where a waiting run stands among the runs waiting (#2145).

eta_s is None when it cannot be told (no node to run on, or too little history) — a missing number, never a zero.

position: int = PydanticUndefined
length: int = PydanticUndefined
eta_s: int | None = None
class SmokeRunTiming(ctfy.core.models.CtfyModel):
2360class SmokeRunTiming(CtfyModel):
2361    """When one smoke run waited, ran and ended — the task card's header
2362    (#2145, the shape #2098 gives every task).
2363
2364    ``expected_s`` is the median of recent finished runs, ``None`` while
2365    there are too few to call one typical. ``recent`` is the verdicts of the
2366    upload's latest stored runs, newest first, for the row of dots.
2367    """
2368
2369    task_id: str
2370    status: TaskStatus = "pending"
2371    queued_at: AwareDatetime | None = None
2372    started_at: AwareDatetime | None = None
2373    ended_at: AwareDatetime | None = None
2374    expected_s: int | None = None
2375    recent: list[AuthoredSmokeState] = Field(default_factory=list)

When one smoke run waited, ran and ended — the task card's header (#2145, the shape #2098 gives every task).

expected_s is the median of recent finished runs, None while there are too few to call one typical. recent is the verdicts of the upload's latest stored runs, newest first, for the row of dots.

task_id: str = PydanticUndefined
status: Literal['pending', 'running', 'succeeded', 'failed', 'cancelled', 'interrupted'] = 'pending'
queued_at: pydantic.types.AwareDatetime | None = None
started_at: pydantic.types.AwareDatetime | None = None
ended_at: pydantic.types.AwareDatetime | None = None
expected_s: int | None = None
recent: list[typing.Union[typing.Literal[''], typing.Literal['solved', 'unsolved', 'estate_failed', 'malformed', 'errored']]] = PydanticUndefined
class SolvePoint(ctfy.core.models.CtfyModel):
59class SolvePoint(CtfyModel):
60    """The solve curve: ``solvers`` teams had finished by ``at``."""
61
62    at: AwareDatetime
63    solvers: int

The solve curve: solvers teams had finished by at.

at: pydantic.types.AwareDatetime = PydanticUndefined
solvers: int = PydanticUndefined
class SolveRef(ctfy.core.models.CtfyModel):
20class SolveRef(CtfyModel):
21    """One question of one challenge: the solve's identity within a team."""
22
23    challenge_id: str = Field(min_length=1, max_length=200)
24    flag_id: str = Field(default="flag", min_length=1, max_length=200)

One question of one challenge: the solve's identity within a team.

challenge_id: str = PydanticUndefined
flag_id: str = 'flag'
class SshCredential(ctfy.core.models.CtfyModel):
796class SshCredential(CtfyModel):
797    """Everything a defender needs to `ssh` into their own box, once.
798
799    Model (b): the platform mints the keypair rather than a player
800    registering one, so both halves are here. The private key is
801    returned exactly once and never stored — there is nothing to leak
802    later and nothing to revoke, because it dies with the certificate's
803    five-minute window.
804
805    ⚠️ **The username is not cosmetic.** The bastion reads the subject
806    back off the login name, and asyncssh refuses a certificate whose
807    signed principal list omits it — so a client that connects under any
808    other name is rejected at the handshake with nothing in the response
809    explaining why.
810    """
811
812    #: Which transport this credential is for — see ``AwdShellExposure``.
813    #: ``managed`` (the default) is the cert/bastion shape below; ``open``
814    #: is the classic-AWD box-local ``sshd``, where the fields that carry
815    #: are ``username`` / ``password`` / ``host`` / ``port`` and the cert
816    #: pair is empty. A client branches on this rather than guessing from
817    #: which fields are set (#1725).
818    exposure: str = AwdShellExposure.MANAGED.value
819    #: OpenSSH private key (``managed`` only). Returned once; the platform
820    #: keeps no copy. ``""`` under ``open`` exposure.
821    private_key: str = ""
822    #: The certificate over it, signed by the platform's SSH CA
823    #: (``managed`` only). ``""`` under ``open`` exposure.
824    certificate: str = ""
825    #: The login name. ``user_id:instance_id`` under ``managed`` (the
826    #: bastion reads the subject off it); the box's login user (``root``)
827    #: under ``open``.
828    username: str
829    #: The starting password (``open`` exposure only). ``""`` under
830    #: ``managed``, which authenticates with the cert. The owner rotates
831    #: it with ``appctl passwd`` after logging in.
832    password: str = ""
833    #: Where to present it. Under ``managed`` this is the bastion, which
834    #: only the operator knows how to publish. Under ``open`` it is the
835    #: box's own arena address, reached over the caller's arena tunnel.
836    host: str
837    port: int
838    #: Seconds the credential stays useful for *connecting*. Under
839    #: ``managed`` it is the certificate's short life; under ``open`` the
840    #: password lasts the match (until the defender rotates it), so this
841    #: is the match's remaining seconds as an advisory, not a hard expiry.
842    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):
776class SshExchangeRequest(CtfyModel):
777    """The subject a bastion has already authenticated by certificate.
778
779    ⚠️ **Not the certificate.** That was the first shape and it cannot
780    be built: asyncssh discards the client certificate once it has
781    validated it, so the bastion has nothing to forward. What survives
782    the handshake is the login name, which the CA signs — a certificate
783    whose principal list omits the requested username is refused — so
784    the bastion knows the subject but cannot re-prove it with the
785    credential. The bearer on this request is what makes the claim
786    trustworthy; see ``routes/ssh_exchange.py``.
787    """
788
789    #: Who the certificate named. Authorisation still runs against them.
790    user_id: str
791    #: Which box. Re-resolved here — it can stop or change hands between
792    #: the certificate mint and the connect.
793    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 SsoBindingInfo(ctfy.core.models.CtfyModel):
355class SsoBindingInfo(CtfyModel):
356    """One bound GitHub / Google / CARSI / OIDC identity, for the badge's tooltip.
357
358    ``login`` is the handle the provider released: the GitHub login, else
359    the identity's address, else its display name. ``signup`` is whether
360    the account's ``user_registered`` row names this provider; an account
361    older than that row reads ``False``, never a guess from timestamps.
362    """
363
364    #: The identity's own id: the key ``/admin/users/{id}/identities/{id}/unlink``
365    #: takes (#2777).
366    id: str = ""
367    provider: OAuthProviderName
368    login: str = ""
369    #: CARSI only: the school's scope domain and the role it attested there.
370    affiliation_domain: str = ""
371    affiliation_role: AffiliationKind = ""
372    bound_at: datetime | None = None
373    last_used_at: datetime | None = None
374    signup: bool = False

One bound GitHub / Google / CARSI / OIDC identity, for the badge's tooltip.

login is the handle the provider released: the GitHub login, else the identity's address, else its display name. signup is whether the account's user_registered row names this provider; an account older than that row reads False, never a guess from timestamps.

id: str = ''
provider: Literal['github', 'google', 'oidc', 'carsi'] = PydanticUndefined
login: str = ''
affiliation_domain: str = ''
affiliation_role: Literal['', 'student', 'faculty', 'member'] = ''
bound_at: datetime.datetime | None = None
last_used_at: datetime.datetime | None = None
signup: bool = False
StandardCheckState = typing.Literal['unchecked', 'clean', 'advice', 'errors', 'unavailable']
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):
653class StartRequest(CtfyModel):
654    challenge_id: str
655    # Per-instance TTL override in seconds. ``None`` (the default)
656    # defers to the platform's ``default_instance_ttl_s`` setting —
657    # admin-tunable, currently 24h. Explicit values are clamped to
658    # ``max_instance_ttl_s`` so a misbehaving SDK / agent can't pin
659    # a node by requesting an arbitrarily large TTL.
660    ttl: int | None = None
661    # Which competition this instance is for. The platform looks up
662    # the caller's per-comp team via ``(user_id, competition_id)`` and
663    # stamps the instance with that team. Optional only when the
664    # caller is on exactly one team — multi-comp users always pass
665    # it explicitly.
666    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):
669class StartResponse(CtfyModel):
670    id: str
671    status: InstanceStatus = InstanceStatus.STARTING
672    #: ``queue_position > 0`` when the start was accepted into the start
673    #: queue because no node had room (#2129); ``None`` otherwise.
674    start_wait: StartWait | 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
status: ctfy.core.enums.InstanceStatus = <InstanceStatus.STARTING: 'starting'>
start_wait: StartWait | None = None
class StartWait(ctfy.core.models.CtfyModel):
 87class StartWait(CtfyModel):
 88    """What a player waiting on a start is told about the wait (#2129).
 89
 90    Two different waits, never both at once: ``queue_position`` while the
 91    start is held in line for a node, ``expected_start_*`` while a placed
 92    start is building or pulling. ⚠️ **Every figure is optional and ``None``
 93    means "do not show a number"** -- an estimate drawn from too few starts
 94    is a promise the platform cannot keep (``MIN_EXPECTED_SAMPLES``).
 95    ``queue_position == 0`` means *not queued*, never *first*: the head of
 96    the line is ``1``.
 97    """
 98
 99    #: 1-based place in the start queue; ``0`` = not waiting in line.
100    queue_position: int = 0
101    #: Seconds until that place is served, read from how fast the line has
102    #: moved lately. ``None`` until it has moved enough to say.
103    queue_wait_s: int | None = None
104    #: p50 / p90 of this challenge's recent starts on the same footing (cold
105    #: or warm), in seconds. Present only when the start is notably slow
106    #: (above ``SLOW_START_S``) and at least ``MIN_EXPECTED_SAMPLES`` starts
107    #: stand behind it.
108    expected_start_p50_s: int | None = None
109    expected_start_p90_s: int | None = None

What a player waiting on a start is told about the wait (#2129).

Two different waits, never both at once: queue_position while the start is held in line for a node, expected_start_* while a placed start is building or pulling. ⚠️ Every figure is optional and None means "do not show a number" -- an estimate drawn from too few starts is a promise the platform cannot keep (MIN_EXPECTED_SAMPLES). queue_position == 0 means not queued, never first: the head of the line is 1.

queue_position: int = 0
queue_wait_s: int | None = None
expected_start_p50_s: int | None = None
expected_start_p90_s: int | None = None
class StopResponse(ctfy.core.models.CtfyModel):
677class StopResponse(CtfyModel):
678    status: InstanceStatus = InstanceStatus.STOPPED
679    id: str
680    #: #3208: the node did not answer the stop. The instance is stopped for
681    #: the team -- its slot is free and it is no longer listed -- and the
682    #: platform tears its containers down once the node returns. ``False``
683    #: (the default an older server implies) is a stop the node confirmed.
684    teardown_pending: 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.

status: ctfy.core.enums.InstanceStatus = <InstanceStatus.STOPPED: 'stopped'>
id: str = PydanticUndefined
teardown_pending: bool = False
class SubmissionCreate(ctfy.core.models.CtfyModel):
963class SubmissionCreate(CtfyModel):
964    # ``instance_id`` is the canonical handle since 0.2: the server
965    # reads challenge_id and competition_id off the instance row, so
966    # callers don't need to repeat them and N instances per
967    # (team, challenge) are unambiguous on submit.
968    instance_id: str
969    # Which question on the challenge the agent is answering. Defaults
970    # to ``"flag"`` so single-question challenges stay one-liner submits;
971    # multi-question challenges (see ``ChallengeInfo.questions``) must
972    # specify the id explicitly.
973    question_id: str = "flag"
974    # The agent's submission. String for ``dynamic`` / ``static`` /
975    # ``single_select`` questions; list of strings for ``multi_select``
976    # (e.g. ``["idor", "ssrf"]``). The server picks the grader by
977    # consulting ``spec.questions[i].mode`` on the live instance.
978    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):
 981class SubmissionResponse(CtfyModel):
 982    id: str
 983    correct: bool
 984    solve_time_s: float = 0  # seconds from instance start to correct submission
 985    # 1 = first blood, 2 = second, 3 = third, 4+ = regular solve.
 986    # 0 when incorrect or when the team already had a prior correct solve.
 987    # Ranks within the specific question — independent of the
 988    # full-challenge solve rank.
 989    solve_rank: int = 0
 990    # The question id this submission targeted. Echoed back to the
 991    # caller so simple submission UIs don't have to thread the id
 992    # separately. Equal to the request's ``question_id``.
 993    question_id: str | None = None
 994    # True iff this team has now captured every question declared on
 995    # the challenge. Single-question challenges: always equals ``correct``.
 996    challenge_fully_solved: bool = False
 997    # Remaining wrong-attempt budget for the question this submission
 998    # targeted, AFTER this attempt is counted (the cap is on wrongs;
 999    # correct submissions don't consume budget). ``None`` when the
1000    # question's mode is uncapped (dynamic free-form, or any mode the
1001    # operator opted out of via ``question_attempt_caps``). The UI
1002    # uses this to decrement its remaining-attempts badge without
1003    # re-fetching the instance — see ``ctfy.server.submission_policy``.
1004    attempts_remaining: int | None = None
1005    # Whether the team's capture of this question is on the
1006    # competition's board. Only meaningful when ``correct``.
1007    #
1008    # ⚠️ **Defaults to False on purpose**, which is the loud direction.
1009    # A competition accepts submissions outside its play window and
1010    # simply does not count them (only a competition declaring
1011    # ``sessions`` refuses), so before this field the player got
1012    # ``correct: true`` and scored nothing, with nothing anywhere
1013    # saying why. A producer that forgets to set this makes every solve
1014    # claim it did not count — visible in a minute; the other default
1015    # restores exactly the silence this exists to end.
1016    ranked: bool = False
1017    # Why it is not on the board, when it is not.
1018    #
1019    # ⚠️ **A refinement of ``ranked``, never a second answer to it.** A
1020    # client gates on ``ranked``; this only decides *which sentence* it
1021    # then shows, because the two members' repairs are opposite —
1022    # ``outside_window`` says come back while the competition is open,
1023    # and that copy under ``own_authored`` is advice that can never pay
1024    # off. ``""`` names no reason at all, which is what an older server
1025    # and a forgetful producer both say.
1026    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', 'voided', 'practice'] = ''
class SuiteBestScore(ctfy.core.models.CtfyModel):
30class SuiteBestScore(CtfyModel):
31    """The best result on a suite by one kind of entrant.
32
33    ``percent`` is of the whole suite for a person or a team (solved ÷ the
34    suite's challenges) and the model's own solve rate (solved ÷ attempted,
35    the figure the model leaderboard ranks on) for a model. ⚠️ ``None`` on
36    ``SuiteInfo``, never a zero entry, when nobody of that kind has a result:
37    a card that said ``0%`` would claim somebody tried and failed.
38    """
39
40    label: str = ""
41    percent: float = 0.0

The best result on a suite by one kind of entrant.

percent is of the whole suite for a person or a team (solved ÷ the suite's challenges) and the model's own solve rate (solved ÷ attempted, the figure the model leaderboard ranks on) for a model. ⚠️ None on SuiteInfo, never a zero entry, when nobody of that kind has a result: a card that said 0% would claim somebody tried and failed.

label: str = ''
percent: float = 0.0
class SuiteCategoryShare(ctfy.core.models.CtfyModel):
15class SuiteCategoryShare(CtfyModel):
16    """How many of a suite's challenges are in one category."""
17
18    category: str = ""
19    count: int = 0

How many of a suite's challenges are in one category.

category: str = ''
count: int = 0
class SuiteDifficulty(ctfy.core.models.CtfyModel):
22class SuiteDifficulty(CtfyModel):
23    """How many of a suite's challenges are at each difficulty."""
24
25    easy: int = 0
26    medium: int = 0
27    hard: int = 0

How many of a suite's challenges are at each difficulty.

easy: int = 0
medium: int = 0
hard: int = 0
class SuiteInfo(ctfy.core.models.CtfyModel):
 44class SuiteInfo(CtfyModel):
 45    """One suite as a card on the 测评集 page.
 46
 47    ⚠️ Not a ``CompetitionInfo``: a suite has no window, no registration and
 48    no countdown, so the card names none of them. Activity and difficulty come
 49    from its challenges and boards; provenance is optional organiser metadata.
 50    """
 51
 52    id: str = ""
 53    title: str = ""
 54    #: The title per locale when this row is a competition (#2230); a
 55    #: browser renders this, ``title`` is its base-locale reading.
 56    title_i18n: LocalizedText = Field(default_factory=dict)
 57    description: LocalizedText = Field(default_factory=dict)
 58    type: SuiteType = "hands_on"
 59    challenge_count: int = 0
 60    #: The corpus snapshot the suite's results are graded against: its own pin
 61    #: (``corpus_sha``) when an organiser set one, else the catalog version this
 62    #: deployment runs (``CTFY_CORPUS_SHA``). Empty when neither is known --
 63    #: never a made-up one.
 64    version: str = ""
 65    #: Optional suite provenance (#2218); empty means the organiser has not stated it.
 66    source_name: str = ""
 67    source_url: str = ""
 68    license: str = ""
 69    maintainer: str = ""
 70    version_date: str = ""
 71    #: Whether ``version`` is the suite's own pin. ⚠️ Only a pinned suite
 72    #: counts the runs of that version alone; an unpinned one counts every
 73    #: version, and the page says which.
 74    version_pinned: bool = False
 75    #: Biggest category first.
 76    categories: list[SuiteCategoryShare] = Field(default_factory=list)
 77    #: The split the card's bar draws (#2218).
 78    difficulty: SuiteDifficulty = Field(default_factory=SuiteDifficulty)
 79    #: What a player needs running; read off the challenges, never declared.
 80    environment: SuiteEnvironment = "none"
 81    #: Models with a result on this suite, the runs behind them, and the
 82    #: people (or teams) who solved something. All read off the two boards
 83    #: the card's best figures already come from.
 84    model_count: int = 0
 85    run_count: int = 0
 86    #: ``None`` while the suite's board is held back from this viewer (silent
 87    #: or frozen): a card that said ``0`` would claim nobody practised.
 88    practitioner_count: int | None = None
 89    #: ``run_count`` is above zero but below ``LOW_SAMPLE_RUNS``: the agent's
 90    #: best figure is a sample. Derived once here, so no client restates 30.
 91    small_sample: bool = False
 92    best_model: SuiteBestScore | None = None
 93    best_human: SuiteBestScore | None = None
 94    #: Whether a user's own agent may enter (the 智能体测评 button).
 95    agents_allowed: bool = True
 96    #: Whether the platform's hosted evaluation runs this suite.
 97    hosted_eval: bool = False
 98    #: Who may take part (#2306): ``private_listed`` is *apply to join*.
 99    access: CompetitionAccess = "public"
100    #: Whether *this viewer* may read and play every challenge -- the
101    #: participation rule's answer (``participation_gate.admits``). ⚠️ True
102    #: for a public suite with no read; ``False`` for an anonymous reader of
103    #: a gated one, whose page then shows the application.
104    can_participate: bool = True
105    #: The free sample challenges an invitation-only suite opens to everyone.
106    #: Ids only, in the order the organisers chose; the page reads their rows
107    #: from the suite's own challenge list, where they are the ones listed.
108    preview_challenge_ids: list[str] = Field(default_factory=list)

One suite as a card on the 测评集 page.

⚠️ Not a CompetitionInfo: a suite has no window, no registration and no countdown, so the card names none of them. Activity and difficulty come from its challenges and boards; provenance is optional organiser metadata.

id: str = ''
title: str = ''
title_i18n: dict[str, str] = PydanticUndefined
description: dict[str, str] = PydanticUndefined
type: Literal['hands_on', 'qa', 'cve'] = 'hands_on'
challenge_count: int = 0
version: str = ''
source_name: str = ''
source_url: str = ''
license: str = ''
maintainer: str = ''
version_date: str = ''
version_pinned: bool = False
categories: list[SuiteCategoryShare] = PydanticUndefined
difficulty: SuiteDifficulty = PydanticUndefined
environment: Literal['none', 'instance', 'vpn', 'mixed'] = 'none'
model_count: int = 0
run_count: int = 0
practitioner_count: int | None = None
small_sample: bool = False
best_model: SuiteBestScore | None = None
best_human: SuiteBestScore | None = None
agents_allowed: bool = True
hosted_eval: bool = False
access: Literal['public', 'private_listed', 'private_hidden'] = 'public'
can_participate: bool = True
preview_challenge_ids: list[str] = PydanticUndefined
class SuiteMatrixCell(ctfy.core.models.CtfyModel):
136class SuiteMatrixCell(CtfyModel):
137    """One model on one suite: the suite's own by-model figure (L12).
138
139    #2614: the mean over that model's campaigns on the suite, like every
140    other figure on the page -- never the pooled best-of.
141    """
142
143    suite_id: str = ""
144    challenges_solved: float = 0.0
145    challenges_attempted: float = 0.0
146    #: ``None`` when nothing was scored (no campaign) -- never a 0 %.
147    solve_rate: float | None = None
148    campaigns: int = 0
149    #: The suite version the cell was measured on; blank with
150    #: ``version_pinned`` false when it pools every version.
151    version: str = ""
152    version_pinned: bool = False
153    low_sample: bool = False
154    #: The viewer's own agent is among the runs this cell merges (#2341).
155    #: Per viewer, after the anonymity projection: a stranger never reads it
156    #: true for somebody else's withheld agent.
157    yours: bool = False

One model on one suite: the suite's own by-model figure (L12).

2614: the mean over that model's campaigns on the suite, like every

other figure on the page -- never the pooled best-of.

suite_id: str = ''
challenges_solved: float = 0.0
challenges_attempted: float = 0.0
solve_rate: float | None = None
campaigns: int = 0
version: str = ''
version_pinned: bool = False
low_sample: bool = False
yours: bool = False
class SuiteMatrixRow(ctfy.core.models.CtfyModel):
160class SuiteMatrixRow(CtfyModel):
161    """One model across the suites it was officially measured on.
162
163    ⚠️ Ordered by name, never by a score: there is no overall rank and no
164    total column (L12), and an order by any figure would be one.
165    """
166
167    model_id: str = ""
168    display_name: str = ""
169    vendor_display_name: str = ""
170    #: Only the suites this model has a result on; a missing suite is "not
171    #: tested", painted "—", never a zero.
172    cells: list[SuiteMatrixCell] = Field(default_factory=list)

One model across the suites it was officially measured on.

⚠️ Ordered by name, never by a score: there is no overall rank and no total column (L12), and an order by any figure would be one.

model_id: str = ''
display_name: str = ''
vendor_display_name: str = ''
cells: list[SuiteMatrixCell] = PydanticUndefined
class SuiteResults(ctfy.core.models.CtfyModel):
175class SuiteResults(CtfyModel):
176    """``GET /suites/results``: every visible suite's official top three and
177    the model × suite matrix (#2341, L12)."""
178
179    suites: list[SuiteResultsRow] = Field(default_factory=list)
180    matrix: list[SuiteMatrixRow] = Field(default_factory=list)
181    #: The run count below which ``low_sample`` is set, so the page states
182    #: the threshold it was given rather than a copy of it.
183    low_sample_runs: int = LOW_SAMPLE_RUNS

GET /suites/results: every visible suite's official top three and the model × suite matrix (#2341, L12).

suites: list[SuiteResultsRow] = PydanticUndefined
matrix: list[SuiteMatrixRow] = PydanticUndefined
low_sample_runs: int = 30
class SuiteResultsRow(ctfy.core.models.CtfyModel):
111class SuiteResultsRow(CtfyModel):
112    """One suite on ``/suites?view=results`` (#2341): its own official board's
113    top three, and nothing pooled with any other suite.
114
115    ⚠️ **No cross-suite figure exists on this model, by design.** Suites differ
116    in size, version and difficulty, so a total over them would rank nothing.
117    """
118
119    id: str = ""
120    title: str = ""
121    title_i18n: LocalizedText = Field(default_factory=dict)
122    type: SuiteType = "hands_on"
123    challenge_count: int = 0
124    #: The version the board is read on; ``version_pinned`` false means every
125    #: version, in which case the rows are ordered and carry no rank.
126    version: str = ""
127    version_pinned: bool = False
128    #: harness × model combinations on the official board.
129    entries: int = 0
130    runs: int = 0
131    low_sample: bool = False
132    #: The first three ``harness × model`` rows of the official board.
133    top: list[PairStanding] = Field(default_factory=list)

One suite on /suites?view=results (#2341): its own official board's top three, and nothing pooled with any other suite.

⚠️ No cross-suite figure exists on this model, by design. Suites differ in size, version and difficulty, so a total over them would rank nothing.

id: str = ''
title: str = ''
title_i18n: dict[str, str] = PydanticUndefined
type: Literal['hands_on', 'qa', 'cve'] = 'hands_on'
challenge_count: int = 0
version: str = ''
version_pinned: bool = False
entries: int = 0
runs: int = 0
low_sample: bool = False
top: list[PairStanding] = PydanticUndefined
class TagStat(ctfy.core.models.CtfyModel):
693class TagStat(CtfyModel):
694    """One axis on the per-tag strength radar.
695
696    ``solved`` is the team's solve count for challenges carrying this
697    tag; ``total`` is the platform-wide challenge count for the tag,
698    so the frontend can render "team / total" or normalize to a 0–1
699    ratio for the radar polygon."""
700
701    tag: str = ""
702    solved: int = 0
703    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 TaskCard(ctfy.core.models.CtfyModel):
36class TaskCard(CtfyModel):
37    """One background task, as a card."""
38
39    id: str = ""
40    kind: str = ""
41    type: TaskType = "system"
42    status: TaskStatus = "pending"
43    #: ⚠️ ``None`` is *has not happened* — never an epoch.
44    queued_at: datetime | None = None
45    started_at: datetime | None = None
46    ended_at: datetime | None = None
47    #: Seconds in the queue / executing, each only where it is a fact: a
48    #: queued task's wait is still running, a cancelled-in-queue task never ran.
49    waited_s: float | None = None
50    ran_s: float | None = None
51    #: The median of this kind's recent successful runs; ``None`` while there
52    #: are too few to call one typical.
53    expected_s: int | None = None
54    #: A waiting task's place among its kind's queue (1 is next) and the wait
55    #: to its start, when both can be read from history.
56    position: int | None = None
57    eta_s: int | None = None
58    progress: float = 0.0
59    #: The task ended for a reason that is the platform's, and does not count
60    #: against the person who started it.
61    platform_fault: bool = False
62    cancel_requested: bool = False
63    subject: TaskCardSubject = Field(default_factory=TaskCardSubject)
64    created_by_user_id: str = ""
65    created_by_name: str = ""
66    #: ⚠️ Operators only; ``""`` for everyone else (see the module docstring).
67    error: str = ""
68    summary: str = ""

One background task, as a card.

id: str = ''
kind: str = ''
type: Literal['smoke', 'eval', 'grading', 'build', 'system'] = 'system'
status: Literal['pending', 'running', 'succeeded', 'failed', 'cancelled', 'interrupted'] = 'pending'
queued_at: datetime.datetime | None = None
started_at: datetime.datetime | None = None
ended_at: datetime.datetime | None = None
waited_s: float | None = None
ran_s: float | None = None
expected_s: int | None = None
position: int | None = None
eta_s: int | None = None
progress: float = 0.0
platform_fault: bool = False
cancel_requested: bool = False
subject: TaskCardSubject = PydanticUndefined
created_by_user_id: str = ''
created_by_name: str = ''
error: str = ''
summary: str = ''
class TaskCardCounts(ctfy.core.models.CtfyModel):
71class TaskCardCounts(CtfyModel):
72    """The page's chips, over the same scope, type and window as the list but
73    not its status: what each chip would show if pressed."""
74
75    all: int = 0
76    active: int = 0
77    failed: int = 0
78    succeeded: int = 0

The page's chips, over the same scope, type and window as the list but not its status: what each chip would show if pressed.

all: int = 0
active: int = 0
failed: int = 0
succeeded: int = 0
class TaskCardList(ctfy.core.models.CtfyModel):
81class TaskCardList(CtfyModel):
82    items: list[TaskCard] = Field(default_factory=list)
83    total: int = 0
84    offset: int = 0
85    limit: int = 50
86    counts: TaskCardCounts = Field(default_factory=TaskCardCounts)
87    #: Which view this is: ``all`` is an operator's, ``visible`` is the
88    #: caller's own and what they may see of their team's and events'.
89    scope: TaskScope = "visible"

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[TaskCard] = PydanticUndefined
total: int = 0
offset: int = 0
limit: int = 50
counts: TaskCardCounts = PydanticUndefined
scope: Literal['visible', 'mine', 'all'] = 'visible'
class TaskCardSubject(ctfy.core.models.CtfyModel):
26class TaskCardSubject(CtfyModel):
27    """What a task is about, resolved to a name a person recognises."""
28
29    type: Literal["authored_challenge", "competition", "campaign", "harness", "none"] = "none"
30    id: str = ""
31    label: str = ""
32    #: The competition an upload belongs to, for the link to its page.
33    competition_id: str = ""

What a task is about, resolved to a name a person recognises.

type: Literal['authored_challenge', 'competition', 'campaign', 'harness', 'none'] = 'none'
id: str = ''
label: str = ''
competition_id: str = ''
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):
152class TeamAffiliationSelection(CtfyModel):
153    """A squad's badge: the choice, its effect, and what it could be.
154
155    ⚠️ **Three fields because two of them can disagree, and the
156    disagreement is the state a captain needs to see.**
157    :attr:`domain` is what the team chose and :attr:`affiliation` what
158    strangers actually see — a badge whose backing has gone (the member
159    who published it left, stopped publishing, or the operator removed
160    the catalogue row) reads as *chosen, showing nothing*, which a model
161    holding only the badge cannot tell from *nothing chosen* and which
162    has a different repair. `AffiliationSelection`'s decision on the
163    personal badge, for the same reason.
164
165    ⚠️ **:attr:`choices` is on the answer rather than a second route**,
166    because the two are one question with one cost: resolving the badge
167    already reads every member's published institution, so a picker
168    fetched separately would pay for that walk twice and could show a
169    captain an option the write then refuses.
170
171    ⚠️ **Published, never merely proved.** ``choices`` holds only what
172    team-mates have chosen to show on their own profiles — see
173    `ctfy.server.affiliations.team_candidates`. A squad whose members
174    all stay private can claim nothing, which is the correct answer
175    rather than a gap.
176    """
177
178    #: The domain the captain chose. ``""`` is *no badge*, the default
179    #: and the opt-out.
180    domain: str = ""
181    #: What that choice resolves to right now, or ``None``.
182    affiliation: AffiliationInfo | None = None
183    #: Every institution at least one current member publishes — the
184    #: set the choice is checked against on every read, which is why it
185    #: is the same set the picker offers.
186    choices: list[AffiliationInfo] = Field(default_factory=list)
187    #: A member withholds their organisation at this sitting (#2458), so the
188    #: badge shows to nobody whatever is chosen. The captain's cue; strangers
189    #: read only the absent badge.
190    withheld: bool = False

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
withheld: bool = False
class TeamAuthoringAgreementInfo(ctfy.core.models.CtfyModel):
2591class TeamAuthoringAgreementInfo(CtfyModel):
2592    """One acceptance of a competition's contributor agreement, made by a
2593    team's captain for the whole team (#1965 B).
2594
2595    Immutable: every upload that relied on it records its ``id``
2596    (``team_agreement_id`` on the upload). ``""`` on a declaration is
2597    *nobody said*; the two grants default to their refusal.
2598    """
2599
2600    id: str
2601    team_id: str
2602    competition_id: str
2603    #: The version accepted, stamped from the competition at that moment,
2604    #: and its link then.
2605    terms_version: str
2606    terms_url: str = ""
2607    license: AuthoredLicenseState = ""
2608    provenance: AuthoredProvenanceState = ""
2609    source_url: str = ""
2610    ai_assisted: AuthoredAiAssistanceState = ""
2611    eval_use: bool = False
2612    credit: AuthoredCredit = "anonymous"
2613    #: The captain who accepted, and what to call them — a user id never
2614    #: travels without its label. ``""`` name is *no name to show*.
2615    accepted_by: str = ""
2616    accepted_by_name: str = ""
2617    accepted_at: AwareDatetime | None = None

One acceptance of a competition's contributor agreement, made by a team's captain for the whole team (#1965 B).

Immutable: every upload that relied on it records its id (team_agreement_id on the upload). "" on a declaration is nobody said; the two grants default to their refusal.

id: str = PydanticUndefined
team_id: str = PydanticUndefined
competition_id: str = PydanticUndefined
terms_version: str = PydanticUndefined
terms_url: str = ''
license: Union[Literal[''], Literal['CC-BY-4.0', 'CC-BY-NC-4.0', 'CC-BY-SA-4.0', 'MIT', 'Apache-2.0', 'platform-only']] = ''
provenance: Union[Literal[''], Literal['original', 'port']] = ''
source_url: str = ''
ai_assisted: Union[Literal[''], Literal['yes', 'no']] = ''
eval_use: bool = False
credit: Literal['display_name', 'anonymous'] = 'anonymous'
accepted_by: str = ''
accepted_by_name: str = ''
accepted_at: pydantic.types.AwareDatetime | None = None
class TeamAuthoringAgreementStatus(ctfy.core.models.CtfyModel):
2620class TeamAuthoringAgreementStatus(CtfyModel):
2621    """Where the caller's team stands against its event's agreement (#1965 B).
2622
2623    What an agent on a VPS reads before it uploads, and what the authoring
2624    page draws: whether the team's captain has accepted the version in
2625    force, and so whether an upload carrying no acceptance of its own will
2626    be taken.
2627    """
2628
2629    competition_id: str
2630    #: The agreement the event names now, ``""`` when it names none — then
2631    #: nothing is accepted by anybody and every other field is empty.
2632    terms_version: str = ""
2633    terms_url: str = ""
2634    #: ``True`` under automatic review: only the captain's acceptance
2635    #: counts, and an upload's own ``accepted_terms_version`` is ignored.
2636    #: ``False``: an upload may still accept the agreement itself, and one
2637    #: that does not relies on the team's.
2638    team_only: bool = False
2639    team_id: str = ""
2640    #: Who accepts for the team: its captain, by id and name.
2641    captain_user_id: str = ""
2642    captain_name: str = ""
2643    #: Whether *this caller* may accept now: the captain, signed in on the
2644    #: web (a fine-grained token never may), with the current version not
2645    #: yet accepted.
2646    can_accept: bool = False
2647    #: The team's acceptance of the version in force. ``None`` is *not
2648    #: accepted*: under ``team_only`` every upload is then refused 422
2649    #: ``authoring_team_terms_not_accepted``.
2650    current: TeamAuthoringAgreementInfo | None = None
2651    #: The team's newest acceptance of an **older** version, when the
2652    #: agreement has moved past it (the captain must accept the new one —
2653    #: an upload relying on it is refused 409 ``authoring_terms_changed``).
2654    #: ``None`` when ``current`` is set or nothing was ever accepted.
2655    previous: TeamAuthoringAgreementInfo | None = None

Where the caller's team stands against its event's agreement (#1965 B).

What an agent on a VPS reads before it uploads, and what the authoring page draws: whether the team's captain has accepted the version in force, and so whether an upload carrying no acceptance of its own will be taken.

competition_id: str = PydanticUndefined
terms_version: str = ''
terms_url: str = ''
team_only: bool = False
team_id: str = ''
captain_user_id: str = ''
captain_name: str = ''
can_accept: bool = False
current: TeamAuthoringAgreementInfo | None = None
previous: TeamAuthoringAgreementInfo | None = None
class TeamCaptainRequest(ctfy.core.models.CtfyModel):
702class TeamCaptainRequest(CtfyModel):
703    """Body for an organiser handing a team to one of its members."""
704
705    # The member to promote. Must already be on the team — promoting an
706    # outsider would be a join and a transfer in one call, which is how
707    # a squad ends up with a captain nobody on it recognises.
708    user_id: str = Field(min_length=1)

Body for an organiser handing a team to one of its members.

user_id: str = PydanticUndefined
class TeamConditionsVerdict(ctfy.core.models.CtfyModel):
471class TeamConditionsVerdict(CtfyModel):
472    """The team's verdict against the registration conditions (#2767).
473
474    ``core.registration_conditions.team_verdict`` projected; the reviewer's
475    完全符合 / 差 N 项 / 不符合 with its one-line reason and the matrix.
476    """
477
478    state: TeamVerdictState
479    #: How many items are unmet or unknown (``missing_n``'s N).
480    missing: int = 0
481    #: The one-line reason: the first hard item, else the first one.
482    reason: ConditionReason = ""
483    #: The enabled blocks, in matrix column order.
484    conditions: list[ConditionId] = Field(default_factory=list)
485    unmet: list[ConditionUnmetItem] = Field(default_factory=list)
486    members: list[ConditionMemberRow] = Field(default_factory=list)
487    team: list[ConditionTeamResult] = Field(default_factory=list)
488    #: The institution key most counted members share; ``""`` = none or off.
489    common_institution: str = ""
490    on_ineligible: OnIneligible = "refuse"
491    #: The only gaps are members' blank answers (``core...form_only``): what
492    #: "批量驳回并发待办" may reject, one to-do per blank field.
493    form_only: bool = False
494
495    @classmethod
496    def of(cls, verdict: TeamVerdict, on_ineligible: OnIneligible) -> TeamConditionsVerdict:
497        return cls(
498            state=verdict.state,
499            missing=verdict.missing,
500            reason=verdict.reason,
501            conditions=list(verdict.conditions),
502            unmet=[
503                ConditionUnmetItem(
504                    condition=i.condition, status=i.status, reason=i.reason, user_id=i.user_id
505                )
506                for i in verdict.unmet
507            ],
508            members=[
509                ConditionMemberRow(
510                    user_id=row.user_id,
511                    exempt=row.exempt,
512                    cells=[
513                        ConditionCellInfo(condition=c.condition, status=c.status, reason=c.reason)
514                        for c in row.cells
515                    ],
516                    missing_fields=list(row.missing_fields),
517                )
518                for row in verdict.members
519            ],
520            team=[
521                ConditionTeamResult(condition=r.condition, status=r.status, reason=r.reason)
522                for r in verdict.team
523            ],
524            common_institution=verdict.common_institution,
525            on_ineligible=on_ineligible,
526            form_only=form_only(verdict),
527        )

The team's verdict against the registration conditions (#2767).

core.registration_conditions.team_verdict projected; the reviewer's 完全符合 / 差 N 项 / 不符合 with its one-line reason and the matrix.

state: Literal['fully_eligible', 'missing_n', 'ineligible'] = PydanticUndefined
missing: int = 0
reason: Literal['', 'proof_missing', 'proof_unverified', 'role_not_accepted', 'role_unknown', 'institution_not_listed', 'institution_mixed', 'team_too_small', 'team_too_large', 'registration_missing', 'registration_incomplete', 'blocked_by_proof', 'blocked_by_institution', 'blocked_by_role'] = ''
conditions: list[typing.Literal['proof', 'role', 'institution', 'same_institution', 'team_size', 'registration_complete']] = PydanticUndefined
unmet: list[ConditionUnmetItem] = PydanticUndefined
members: list[ConditionMemberRow] = PydanticUndefined
team: list[ConditionTeamResult] = PydanticUndefined
common_institution: str = ''
on_ineligible: Literal['refuse', 'review'] = 'refuse'
form_only: bool = False
@classmethod
def of( cls, verdict: ctfy.core.registration_conditions.TeamVerdict, on_ineligible: Literal['refuse', 'review']) -> TeamConditionsVerdict:
495    @classmethod
496    def of(cls, verdict: TeamVerdict, on_ineligible: OnIneligible) -> TeamConditionsVerdict:
497        return cls(
498            state=verdict.state,
499            missing=verdict.missing,
500            reason=verdict.reason,
501            conditions=list(verdict.conditions),
502            unmet=[
503                ConditionUnmetItem(
504                    condition=i.condition, status=i.status, reason=i.reason, user_id=i.user_id
505                )
506                for i in verdict.unmet
507            ],
508            members=[
509                ConditionMemberRow(
510                    user_id=row.user_id,
511                    exempt=row.exempt,
512                    cells=[
513                        ConditionCellInfo(condition=c.condition, status=c.status, reason=c.reason)
514                        for c in row.cells
515                    ],
516                    missing_fields=list(row.missing_fields),
517                )
518                for row in verdict.members
519            ],
520            team=[
521                ConditionTeamResult(condition=r.condition, status=r.status, reason=r.reason)
522                for r in verdict.team
523            ],
524            common_institution=verdict.common_institution,
525            on_ineligible=on_ineligible,
526            form_only=form_only(verdict),
527        )
class TeamDetail(ctfy.server.models.TeamInfo):
111class TeamDetail(TeamInfo):
112    """``GET /teams/{id}`` payload — adds the resolved members list."""
113
114    members: list[TeamMemberInfo] = Field(default_factory=list)
115    #: Why the *captain* cannot rename the team right now (#2238): ``started``
116    #: (the event is under way and it locks renames), ``frozen`` (the board is
117    #: frozen) or ``""`` (open). ⚠️ ``""`` means *open*, so a client reading an
118    #: older server's response without the field offers the rename and lets the
119    #: server's ``team_name_locked`` answer -- the direction that costs a click,
120    #: never the one that takes the control away.
121    rename_locked: Literal["", "started", "frozen"] = ""

GET /teams/{id} payload — adds the resolved members list.

members: list[TeamMemberInfo] = PydanticUndefined
rename_locked: Literal['', 'started', 'frozen'] = ''
class TeamDisqualifyRequest(ctfy.core.models.CtfyModel):
608class TeamDisqualifyRequest(CtfyModel):
609    """Body for an organiser removing a team from a competition."""
610
611    # Mandatory, and with a floor rather than merely non-empty. This is
612    # the row an appeal is argued from and the text the team is shown
613    # when their next submission is refused; "cheating" tells them
614    # nothing they can answer. Same reasoning as the admin shell's
615    # mandatory ≥10-char reason, which exists for the same audience.
616    reason: str = Field(min_length=10, max_length=2000)
617    #: The risk case this answers, if it came from one; the case is then
618    #: closed as *actioned* (#2068).
619    case_id: str = ""
620    #: Tell the team (banner + mail). Off = a silent action: recorded, but the
621    #: team sees nothing and so has nothing to appeal. Defaults on.
622    notify_team: bool = True

Body for an organiser removing a team from a competition.

reason: str = PydanticUndefined
case_id: str = ''
notify_team: bool = True
class TeamInfo(ctfy.core.models.CtfyModel):
28class TeamInfo(CtfyModel):
29    """A team's public-facing summary.
30
31    Post user/team-split a team is a pure container — no email, no
32    role, no human profile fields. ``solves`` and ``attempts`` aggregate
33    across every member's contribution while they were on this team.
34    """
35
36    id: str
37    name: str
38    description: str = ""
39    captain_user_id: str = ""
40    captain_display_name: str = ""
41    captain_avatar_url: str = ""
42    member_count: int = 0
43    created_at: datetime | None = None
44    solves: int = 0
45    attempts: int = 0
46    last_active_at: datetime | None = None
47    # Per-competition scoping — every team belongs to exactly one
48    # competition.
49    competition_id: str = ""
50    #: See :attr:`ctfy.server.models.ScoreboardEntry.anonymous`.
51    #:
52    #: ⚠️ ``solves`` / ``attempts`` / ``member_count`` stay on a withheld
53    #: row and are not the leak they look like. The de-anonymising join
54    #: this route made possible was *name × solve count* against the
55    #: board; with the name withheld on both sides there is nothing to
56    #: join to, and a bare progress count names nobody. Withholding them
57    #: as well would make an anonymous entrant's card a row of dashes
58    #: for no gain.
59    anonymous: bool = False
60    #: The institution **this squad** publishes, or ``None``.
61    #:
62    #: ⚠️ **The team's, never a member's, and the field name is the
63    #: whole hazard.** A person's badge is
64    #: :attr:`ctfy.server.models.UserInfo.affiliation` and derives from
65    #: ``users.affiliation_email``; this one derives from
66    #: ``teams.affiliation_domain``. Two subjects, one word — so a
67    #: surface that renders "the affiliation" without saying whose is
68    #: how a captain's university ends up printed against a squad that
69    #: never chose it. :data:`ctfy.server.models.TeamMemberInfo` carries
70    #: none for exactly that reason.
71    #:
72    #: ⚠️ **Composed, never stored resolved**, the same as the personal
73    #: badge: the domain is on the row and the institution is looked up
74    #: on every read, so the badge vanishes when the operator deletes
75    #: the catalogue entry or the last member who published it leaves.
76    #:
77    #: ⚠️ **Cleared under an alias.** An institution beside a
78    #: pseudonym is stronger identity than the name it replaced — "the
79    #: Tsinghua entrant" narrows a field of two hundred to a handful —
80    #: so `ctfy.server.anonymity` blanks it wherever it blanks the name;
81    #: `test_the_badge_never_reaches_an_alias` drives every applier that
82    #: reaches this model and asserts so.
83    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):
551class TeamInviteInfo(CtfyModel):
552    """Public projection of a :class:`TeamInviteState`.
553
554    Surfaces on captain CRUD. ``active`` is server-derived from the
555    expires/use/revoked triple. ``kind`` discriminates code-style
556    invites from directed invites/requests; ``target_user_id`` is
557    the recipient for ``kind="direct"`` (set at mint time) and the
558    requester for ``kind="join_request"`` (set when a member opens
559    a request).
560    """
561
562    id: str
563    code: str
564    team_id: str
565    created_by_user_id: str
566    created_at: datetime | None = None
567    expires_at: datetime | None = None
568    max_uses: int
569    use_count: int
570    revoked_at: datetime | None = None
571    active: bool = True
572    competition_id: str = ""
573    kind: Literal["code", "direct", "join_request"] = "code"
574    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 TeamInvitePreview(ctfy.core.models.CtfyModel):
593class TeamInvitePreview(CtfyModel):
594    """What a signed-out visitor holding a team's invite link may be told (#2281).
595
596    ``GET /competitions/{id}/invite-preview/{code}``. Exactly two facts: the
597    squad's name and the event's title, so the sign-in page can say where the
598    link leads. Never a member, a count, a captain or an id -- the lookup is
599    public, and the code is the only thing the caller proved.
600    """
601
602    team_name: str
603    competition_title: str
604    #: The title per locale, so a browser renders it in its own language.
605    competition_title_i18n: LocalizedText = Field(default_factory=dict)

What a signed-out visitor holding a team's invite link may be told (#2281).

GET /competitions/{id}/invite-preview/{code}. Exactly two facts: the squad's name and the event's title, so the sign-in page can say where the link leads. Never a member, a count, a captain or an id -- the lookup is public, and the code is the only thing the caller proved.

team_name: str = PydanticUndefined
competition_title: str = PydanticUndefined
competition_title_i18n: dict[str, str] = PydanticUndefined
class TeamLogoUploadResponse(ctfy.core.models.CtfyModel):
1124class TeamLogoUploadResponse(CtfyModel):
1125    """``POST /competitions/{id}/team/logo`` result."""
1126
1127    object_key: str
1128    content_type: str = ""
1129    size_bytes: int = 0

POST /competitions/{id}/team/logo result.

object_key: str = PydanticUndefined
content_type: str = ''
size_bytes: int = 0
class TeamMachineLogin(ctfy.core.models.CtfyModel):
154class TeamMachineLogin(CtfyModel):
155    """A machine's login, for the captain. ⚠️ The only secret-bearing model."""
156
157    competition_id: str
158    machine_id: str
159    host: str
160    port: int
161    username: str
162    password: str = Field(default="", repr=False)
163    private_key: str = Field(default="", repr=False)
164    note: str = ""
165    assigned_at: datetime | None = None

A machine's login, for the captain. ⚠️ The only secret-bearing model.

competition_id: str = PydanticUndefined
machine_id: str = PydanticUndefined
host: str = PydanticUndefined
port: int = PydanticUndefined
username: str = PydanticUndefined
password: str = ''
private_key: str = ''
note: str = ''
assigned_at: datetime.datetime | None = None
class TeamMachineStatus(ctfy.core.models.CtfyModel):
144class TeamMachineStatus(CtfyModel):
145    """What any member of a team may learn: whether a machine exists and
146    whether *they* may open it. No host, no username."""
147
148    competition_id: str
149    assigned: bool = False
150    can_view: bool = False
151    assigned_at: datetime | None = None

What any member of a team may learn: whether a machine exists and whether they may open it. No host, no username.

competition_id: str = PydanticUndefined
assigned: bool = False
can_view: bool = False
assigned_at: datetime.datetime | None = None
class TeamMemberInfo(ctfy.core.models.CtfyModel):
 86class TeamMemberInfo(CtfyModel):
 87    """One member row on the team-detail page."""
 88
 89    user_id: str
 90    display_name: str = ""
 91    avatar_url: str = ""
 92    is_captain: bool = False
 93    joined_at: datetime | None = None
 94    #: This member's *own* name is withheld — either because they asked
 95    #: or because the squad around them is anonymous. See
 96    #: :attr:`ctfy.server.models.ScoreboardEntry.anonymous`.
 97    anonymous: bool = False
 98
 99    # ⚠️ **No ``affiliation`` here, and it is a decision rather than an
100    # omission.** A roster row is a *person* inside a *team*, so a badge
101    # on it would be the person's while every alias around it is decided
102    # about the squad: ``withholds_team`` rules on the row this sits in,
103    # ``withholds_user`` on the row itself, and ``_is_own_team`` is a
104    # third predicate that shows a captain their own members' identities.
105    # A per-person attribute one forgotten branch from a withheld squad
106    # is the shape this repository has paid for; the surfaces the badge
107    # was asked for are the team's, and `TeamInfo.affiliation` carries
108    # 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 TeamNameChangeRow(ctfy.core.models.CtfyModel):
368class TeamNameChangeRow(CtfyModel):
369    """One rename in a team's history, for the organiser's review card (#2238).
370
371    Plain text throughout: ``old_name`` / ``new_name`` are what a person typed
372    and are drawn as text, never as markup or a link.
373    """
374
375    id: str
376    changed_at: datetime | None = None
377    old_name: str = ""
378    new_name: str = ""
379    #: Who did it, by the platform's one label for a person; ``""`` for the
380    #: migration that de-duplicated names (``actor_kind == "migration"``).
381    actor_user_id: str = ""
382    actor_name: str = ""
383    actor_kind: TeamNameActor = "captain"

One rename in a team's history, for the organiser's review card (#2238).

Plain text throughout: old_name / new_name are what a person typed and are drawn as text, never as markup or a link.

id: str = PydanticUndefined
changed_at: datetime.datetime | None = None
old_name: str = ''
new_name: str = ''
actor_user_id: str = ''
actor_name: str = ''
actor_kind: Literal['captain', 'organiser', 'migration'] = 'captain'
class TeamNameCheck(ctfy.core.models.CtfyModel):
124class TeamNameCheck(CtfyModel):
125    """The answer to "may I call my team this?" (#2238) -- the registration
126    form's inline check, from the one rule every door applies.
127
128    ``name`` is what would be **stored** (normalised), so the form can show the
129    person what the platform will use. ``refusal`` is a ``common:error.<code>``
130    code, ``""`` when the name is fine.
131    """
132
133    name: str = ""
134    available: bool = False
135    refusal: TeamNameRefusal | Literal["", "team_name_taken"] = ""

The answer to "may I call my team this?" (#2238) -- the registration form's inline check, from the one rule every door applies.

name is what would be stored (normalised), so the form can show the person what the platform will use. refusal is a common:error.<code> code, "" when the name is fine.

name: str = ''
available: bool = False
refusal: Union[Literal['team_name_required', 'team_name_too_short', 'team_name_too_long', 'team_name_has_link', 'team_name_reserved', 'team_name_blocked'], Literal['', 'team_name_taken']] = ''
class TeamPracticeSummary(ctfy.core.models.CtfyModel):
1039class TeamPracticeSummary(CtfyModel):
1040    """What the caller's own team has done in practice in one competition
1041    (#2183) -- the team page's 「另有 N 道练习」. Never a solve, never a rank:
1042    nothing that scores reads it, and only the team's own members do."""
1043
1044    competition_id: str
1045    team_id: str
1046    #: Distinct challenges with at least one question answered right in
1047    #: practice -- fully or partly. ``0`` also when the competition withholds
1048    #: the team's own verdicts (a silent exercise).
1049    challenge_count: int = 0
1050    #: The first right practice answer per question, oldest first.
1051    captures: list[PracticeCapture] = Field(default_factory=list)

What the caller's own team has done in practice in one competition (#2183) -- the team page's 「另有 N 道练习」. Never a solve, never a rank: nothing that scores reads it, and only the team's own members do.

competition_id: str = PydanticUndefined
team_id: str = PydanticUndefined
challenge_count: int = 0
captures: list[ctfy.server.models.challenge.PracticeCapture] = PydanticUndefined
class TeamSolveRow(ctfy.core.models.CtfyModel):
44class TeamSolveRow(CtfyModel):
45    """One solve of a team, live or voided."""
46
47    challenge_id: str
48    challenge_name: str = ""
49    flag_id: str
50    solved_at: datetime | None = None
51    #: When it entered the board; ``None`` = it never has (outside the window).
52    ranked_at: datetime | None = None
53    voided: bool = False
54    voided_at: datetime | None = None
55    voided_by: str = ""
56    voided_by_display_name: str = ""
57    reason: str = ""

One solve of a team, live or voided.

challenge_id: str = PydanticUndefined
challenge_name: str = ''
flag_id: str = PydanticUndefined
solved_at: datetime.datetime | None = None
ranked_at: datetime.datetime | None = None
voided: bool = False
voided_at: datetime.datetime | None = None
voided_by: str = ''
voided_by_display_name: str = ''
reason: str = ''
class TeamStandingInfo(ctfy.core.models.CtfyModel):
670class TeamStandingInfo(CtfyModel):
671    """Whether a team is currently in the event, and why not.
672
673    A separate model from ``TeamDetail`` on purpose: this is what the
674    two enforcement routes return and what the admin table polls, so it
675    stays small enough to fetch per row without dragging a roster and a
676    logo key along.
677    """
678
679    id: str
680    name: str
681    competition_id: str = ""
682    disqualified: bool = False
683    disqualified_at: datetime | None = None
684    disqualified_by: str = ""
685    disqualification_reason: str = ""
686
687    @classmethod
688    def from_team(cls, team: TeamState) -> TeamStandingInfo:
689        return cls(
690            id=team.id,
691            name=team.name,
692            competition_id=team.competition_id,
693            # Projected as a plain bool so a client never has to know
694            # that "absent timestamp" is how the platform spells "in".
695            disqualified=team.is_disqualified,
696            disqualified_at=team.disqualified_at,
697            disqualified_by=team.disqualified_by,
698            disqualification_reason=team.disqualification_reason,
699        )

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:
687    @classmethod
688    def from_team(cls, team: TeamState) -> TeamStandingInfo:
689        return cls(
690            id=team.id,
691            name=team.name,
692            competition_id=team.competition_id,
693            # Projected as a plain bool so a client never has to know
694            # that "absent timestamp" is how the platform spells "in".
695            disqualified=team.is_disqualified,
696            disqualified_at=team.disqualified_at,
697            disqualified_by=team.disqualified_by,
698            disqualification_reason=team.disqualification_reason,
699        )
class TeamTodoProgressRow(ctfy.core.models.CtfyModel):
1154class TeamTodoProgressRow(CtfyModel):
1155    """One to-do of the caller's team, as a teammate may see it: whose (by
1156    name), which item, done or not -- never a value."""
1157
1158    subject_name: str = ""
1159    #: ``True`` for the caller's own (theirs, or the squad's for a captain).
1160    mine: bool = False
1161    team_level: bool = False
1162    item: RegistrationTodoItem
1163    field: str = ""
1164    status: RegistrationTodoStatus = "open"

One to-do of the caller's team, as a teammate may see it: whose (by name), which item, done or not -- never a value.

subject_name: str = ''
mine: bool = False
team_level: bool = False
item: Literal['field', 'verify_school_email', 'add_member'] = PydanticUndefined
field: str = ''
status: Literal['open', 'done', 'cancelled'] = 'open'
class TestEmailRequest(ctfy.core.models.CtfyModel):
169class TestEmailRequest(CtfyModel):
170    template: str
171    #: Defaults to the caller's own address. An arbitrary recipient is
172    #: allowed on purpose — checking how a template lands in Outlook or
173    #: QQ mail requires mailing an account on that provider — and every
174    #: send is audited with the address.
175    to: str = ""
176    #: Which shape of the message to send: ``zh-CN`` or ``en`` (a recipient
177    #: whose language is known gets one), or ``""`` — the shape a recipient
178    #: whose language is *unknown* gets, the complete Chinese message and
179    #: then the complete English one. A reviewer checks all three.
180    language: MailLanguageOrUnset = ""

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 = ''
language: Literal['', 'en', 'zh-CN'] = ''
class TestEmailResult(ctfy.core.models.CtfyModel):
183class TestEmailResult(CtfyModel):
184    sent: bool
185    to: str
186    template: str
187    subject: str
188    #: The provider's id for the accepted message, for cross-referencing
189    #: its delivery dashboard. Empty when the send failed, and also
190    #: legitimately empty on success if the response could not be parsed
191    #: — ``sent`` is the outcome, this is only a handle on it.
192    message_id: str = ""
193    #: Provider-side reason when ``sent`` is false. Empty on success.
194    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 ThreadEvent(ctfy.core.models.CtfyModel):
143class ThreadEvent(CtfyModel):
144    """A marker between messages (#2284): 「孙浩 10:50 加入队伍」. Read from
145    ``team_memberships.joined_at``, which every current member carries -- there
146    is no second history to drift from it. Only members who joined *after* the
147    thread began are marked; the founders of a thread are not an event."""
148
149    kind: ThreadEventKind
150    at: datetime | None = None
151    #: The person, as this reader may name them (an anonymous entrant by
152    #: their public label for the organisers).
153    label: str = ""

A marker between messages (#2284): 「孙浩 10:50 加入队伍」. Read from team_memberships.joined_at, which every current member carries -- there is no second history to drift from it. Only members who joined after the thread began are marked; the founders of a thread are not an event.

kind: Literal['member_joined'] = PydanticUndefined
at: datetime.datetime | None = None
label: str = ''
class ThreadMember(ctfy.core.models.CtfyModel):
121class ThreadMember(CtfyModel):
122    """One reader on the addressed side of a team thread."""
123
124    label: str
125    is_captain: bool = False

One reader on the addressed side of a team thread.

label: str = PydanticUndefined
is_captain: bool = False
class ThreadStatusRequest(ctfy.core.models.CtfyModel):
253class ThreadStatusRequest(CtfyModel):
254    """Mark a thread resolved, or open it again."""
255
256    status: ThreadStatus

Mark a thread resolved, or open it again.

status: Literal['open', 'resolved'] = PydanticUndefined
class TokenInfo(ctfy.core.models.CtfyModel):
347class TokenInfo(CtfyModel):
348    id: str
349    #: ⚠️ ``agent`` is deprecated and ``0006`` rewrote every row, but it
350    #: stays a member: a legacy dump restored into a new deployment still
351    #: carries it, and a wire model that refuses a value the column can
352    #: hold turns a readable row into a 500.
353    kind: TokenKind
354    label: str = ""
355    created_at: datetime | None = None
356    last_used_at: datetime | None = None
357    # ISO-8601 UTC timestamp; empty means the token never expires.
358    expires_at: datetime | None = None
359    # Client metadata captured when the session was minted. Only populated
360    # for user-kind tokens (browser sessions).
361    ip_address: str = ""
362    user_agent: str = ""
363    # True for the session that made this request — lets the UI show "this
364    # session" and disable the Revoke button so a user can't accidentally
365    # log themselves out.
366    is_current: bool = False
367    # Fine-grained scope summary (empty/"none" for user-kind tokens).
368    competition_access: CompetitionAccessKind = "none"
369    competition_ids: list[str] = Field(default_factory=list)
370    #: Category → level, dumped from :class:`TokenPermissions`, whose
371    #: every field is a ``PermissionLevel``.
372    #:
373    #: ⚠️ **`dict[str, str]` here was the `max_level` shape a third
374    #: time.** The producer is `TokenPermissions.model_dump()`, so the
375    #: values have never been anything but the three-member ladder —
376    #: while the wire published plain `string` and `api.ts` declared
377    #: `Record<string, PermissionLevel>` anyway, i.e. the client
378    #: asserting more than the server promised, about the value every
379    #: scope gate ranks with. Narrowed here so the derivation in
380    #: `@/types` keeps the check rather than losing it.
381    permissions: dict[str, PermissionLevel] = Field(default_factory=dict)
382
383    #: The id of the token this one replaced, or ``""`` for one that was
384    #: minted rather than rotated.
385    #:
386    #: ⚠️ **A column with no reader is an edge nobody walks.** The
387    #: rotation link shipped as ``team_tokens.rotated_from`` with a
388    #: writer and nothing that reads it — so "revoked rows are kept
389    #: precisely so an audit can resolve a ``pf_…`` found in a log" was
390    #: true of the table and unreachable from every surface. This is
391    #: half the repair (the owner's own list); ``POST
392    #: /admin/token-trace`` is the other.
393    #:
394    #: ⚠️ Named for the column and **not** ``rotated_from_id``: the
395    #: ``<entity>_id``-for-a-foreign-key half of the wire-naming rule is
396    #: about a *different entity's* id, and this is the same table —
397    #: ``CompetitionState.virtual_of`` is the shape it follows.
398    rotated_from: str = ""
399
400    #: Whether this token's grants are exactly one of the named
401    #: templates, or ``""`` for a hand-assembled set.
402    #:
403    #: ⚠️ **A ``computed_field``, deliberately not a column.** Which
404    #: template minted a token is a fact that stops being true the
405    #: moment either the token or the catalog is edited; the permission
406    #: map is the authority, so this re-derives from the map every read
407    #: and cannot disagree with it. (``LlmBudgetRow.exhausted`` is the
408    #: same call on a wire model; ``TeamState.is_disqualified`` is the
409    #: opposite one on a state model, where ``model_dump`` feeds
410    #: ``to_orm`` and every dumped key must be a column.)
411    @computed_field
412    @property
413    def template(self) -> TokenTemplateName | Literal[""]:
414        from ctfy.server.token_templates import template_matching
415
416        return template_matching(self.permissions)
417
418    @computed_field
419    @property
420    def ip_location(self) -> IpLocation | None:
421        """Where ``ip_address`` is (#2140) — looked up now, never stored, like
422        ``Activity.ip_location``. ``None`` for a token minted with no address."""
423        if not self.ip_address:
424            return None
425        from ctfy.server.ip_location import locate_ip
426
427        return locate_ip(self.ip_address)
428
429    @computed_field
430    @property
431    def device(self) -> str:
432        """The client as the allow-listed summary (``"Edge 152 · macOS"``, #2151),
433        or ``""`` when the header says nothing the lists know.
434
435        ``""`` rather than a localised "unknown device": the wire has no
436        language, and the page owns that word. The raw ``user_agent`` stays
437        on the wire for whoever needs the header itself."""
438        from ctfy.server.ua_summary import UNKNOWN, summarize_user_agent
439
440        summary = summarize_user_agent(self.user_agent)
441        return "" if summary == UNKNOWN else summary.en

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['']]
411    @computed_field
412    @property
413    def template(self) -> TokenTemplateName | Literal[""]:
414        from ctfy.server.token_templates import template_matching
415
416        return template_matching(self.permissions)
ip_location: ctfy.core.ip_location.IpLocation | None
418    @computed_field
419    @property
420    def ip_location(self) -> IpLocation | None:
421        """Where ``ip_address`` is (#2140) — looked up now, never stored, like
422        ``Activity.ip_location``. ``None`` for a token minted with no address."""
423        if not self.ip_address:
424            return None
425        from ctfy.server.ip_location import locate_ip
426
427        return locate_ip(self.ip_address)

Where ip_address is (#2140) — looked up now, never stored, like Activity.ip_location. None for a token minted with no address.

device: str
429    @computed_field
430    @property
431    def device(self) -> str:
432        """The client as the allow-listed summary (``"Edge 152 · macOS"``, #2151),
433        or ``""`` when the header says nothing the lists know.
434
435        ``""`` rather than a localised "unknown device": the wire has no
436        language, and the page owns that word. The raw ``user_agent`` stays
437        on the wire for whoever needs the header itself."""
438        from ctfy.server.ua_summary import UNKNOWN, summarize_user_agent
439
440        summary = summarize_user_agent(self.user_agent)
441        return "" if summary == UNKNOWN else summary.en

The client as the allow-listed summary ("Edge 152 · macOS", #2151), or "" when the header says nothing the lists know.

"" rather than a localised "unknown device": the wire has no language, and the page owns that word. The raw user_agent stays on the wire for whoever needs the header itself.

class TokenLifetimeRequest(ctfy.core.models.CtfyModel):
575class TokenLifetimeRequest(CtfyModel):
576    """Body for ``renew`` and ``rotate`` — the new lifetime, in days.
577
578    ⚠️ **Measured from now, never added to what is left.** Adding to the
579    remaining time lets a chain of renewals walk a credential's expiry
580    arbitrarily far out while every individual step looks like a
581    30-day one — and "expires in 30 days" is the sentence the page
582    prints, so the drift would be invisible on the only surface that
583    reports it. ``None`` takes the server default, ``0`` is *never
584    expires*: the same encoding ``expires_in_days`` already uses at
585    mint, because two spellings of "no expiry" on one axis is how the
586    expiry picker shipped three answers to one question.
587    """
588
589    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):
541class TokenScopeUpdate(CtfyModel):
542    """Body for ``PATCH /auth/tokens/{id}`` — edit a live token's reach.
543
544    ⚠️ **A partial update, read through ``model_fields_set``.** Every
545    field defaults to ``None`` and ``None`` means *leave it alone*, so
546    a client that sends only ``permissions`` does not blank the
547    competition scope. Reading the whole model instead is the defect
548    the registration editor already paid for: it blanked five columns
549    and landed an audit row naming every field.
550
551    ⚠️ **``competition_ids`` therefore has two absences that are not the
552    same.** ``None`` is *leave the list alone*; ``[]`` is *clear it*,
553    which is what a caller switching to ``none``/``all`` means. Folding
554    them together makes one of the two unsayable.
555
556    ⚠️ **Not here on purpose**: the secret (that is ``rotate``), the
557    expiry (that is ``renew``) and the kind. Each has its own verb with
558    its own refusals, and a PATCH that quietly did any of them would be
559    a fourth way to reach a decision the other three exist to make
560    deliberate.
561    """
562
563    label: str | None = Field(default=None, max_length=64)
564    #: Re-seed from a named template. Explicit fields below still win,
565    #: exactly as at the mint.
566    template: TokenTemplateName | None = None
567    competition_access: str | None = None
568    competition_ids: list[str] | None = None
569    #: ``str``-typed for ``CreateFineGrainedTokenRequest.permissions``'
570    #: reason: this is a request and ``clamp_permissions`` exists to
571    #: lower what arrives rather than refuse it.
572    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):
513class TokenScopesResponse(CtfyModel):
514    """``GET /auth/tokens/scopes`` — the catalog the UI renders."""
515
516    categories: list[ScopeCatalogEntry] = Field(default_factory=list)
517    #: Every assignable level, published so the picker need not hardcode
518    #: the taxonomy — which is what this route's docstring promises.
519    #:
520    #: ⚠️ **Derived from the type, and it was a hand-written default on a
521    #: ``list[str]``.** Two things could drift from each other with
522    #: nothing red: the value here and ``PermissionLevel`` itself, and
523    #: this field's ``str`` against ``frontend/src/api.ts``, which
524    #: already declares the same field ``PermissionLevel[]`` — the wire
525    #: was the looser of the two, so the client's stricter claim rested
526    #: on a promise the server did not make.
527    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):
530class TokenTemplatesResponse(CtfyModel):
531    """``GET /auth/tokens/templates`` — the named starting points.
532
533    Carries the catalog's own models rather than a copy of them: the
534    route publishes exactly what ``POST /auth/tokens`` will mint from,
535    so the picker cannot show a grant the mint does not make.
536    """
537
538    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):
592class TokenTraceRequest(CtfyModel):
593    """Body for ``POST /admin/token-trace``.
594
595    ⚠️ **The secret travels in the body, never a query parameter** —
596    ``AnswerTraceRequest``'s reason, on a value that is strictly worse
597    to leak: a live bearer in a URL lands in every access log, proxy
598    cache and browser history between the operator and the app, which
599    is the exact accident this route exists to clean up after.
600    """
601
602    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):
639class TokenTraceResponse(CtfyModel):
640    """Result of ``POST /admin/token-trace``.
641
642    ⚠️ **A miss is ``found: false`` at 200, never a 404.** "That string
643    is not a token this deployment ever minted" is the answer the
644    operator asked for — it is what clears a scare — while a 404 reads
645    as *the route is wrong* and sends them to check the URL. The
646    registry's don't-leak-existence rule does not transfer: the caller
647    is a super-admin who already holds every token row.
648
649    ``chain`` is the whole rotation lineage, oldest first, so the reply
650    to *"this secret is in a paste bin"* carries the thing the operator
651    needs next — which token is live now. A token that was never
652    rotated yields a chain of one.
653    """
654
655    found: bool = False
656    #: The traced row's id, or ``""``. Present beside ``chain`` because
657    #: the id is what ``TokensPage`` prints and what a revoke names.
658    token_id: str = ""
659    owner_user_id: str = ""
660    owner_display_name: str = ""
661    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):
558class TrafficWitness(CtfyModel):
559    """One address seen on a box, and the second it was seen in.
560
561    §7.1's correlation asks a gamebox's capture *who touched it*, and
562    this is that answer reduced to what the question needs. The whole
563    flow — both bodies, ~100 kB apiece — never crosses the wire for it.
564
565    ⚠️ ``second`` is ``""`` when the capture carried no timestamp, and
566    a reader must treat that as **every round**, never as epoch. It is
567    still evidence somebody was there; the only thing it cannot do is
568    say which round, so it may clear a capture and may not place one.
569    """
570
571    ip: str = ""
572    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):
712class TrendPoint(CtfyModel):
713    """One UTC-day bucket on the solve-trend line."""
714
715    date: str = ""  # YYYY-MM-DD, UTC
716    solves: int = 0

One UTC-day bucket on the solve-trend line.

date: str = ''
solves: int = 0
class UnresolvedPin(ctfy.core.models.CtfyModel):
96class UnresolvedPin(CtfyModel):
97    machine_id: str
98    problem: PinProblem

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.

machine_id: str = PydanticUndefined
problem: Literal['unknown_team', 'ambiguous_team', 'team_served'] = PydanticUndefined
class UpdateUserRoleRequest(ctfy.core.models.CtfyModel):
511class UpdateUserRoleRequest(CtfyModel):
512    """Body for ``PATCH /admin/users/{user_id}/role``.
513
514    Only ``"admin"`` and ``"user"`` are accepted — granting
515    ``"super_admin"`` is reserved for the env allowlist
516    (``CTFY_SUPER_ADMIN_EMAILS``) so the trust anchor stays out-of-band.
517    """
518
519    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):
499class UpdateUserStandingsRequest(CtfyModel):
500    """Body for ``PATCH /admin/users/{user_id}/standings``.
501
502    ``excluded`` is the *decision*, not a timestamp: the caller says
503    whether the account counts, and the server stamps when. A body
504    carrying the moment would let two admins disagree about it and would
505    make "undo" mean writing a value rather than clearing one.
506    """
507
508    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 UserAchievements(ctfy.core.models.CtfyModel):
164class UserAchievements(CtfyModel):
165    """``GET /users/{user_id}/achievements``: the profile's 成就 (#2928).
166
167    The badges a person earned across every team they are on, one per
168    badge (earliest unlock), newest first. Gated by the ``achievements``
169    visibility key (absent = shown): a stranger is sent ``items=[]`` with
170    ``shown=False`` when it is off. For anybody but the person and a
171    platform admin, a secret badge is left out (the team page's rule), a
172    sitting the public career withholds contributes nothing, a badge won
173    after a held event's freeze is not there yet, and ``context`` is empty.
174    """
175
176    items: list[TeamAchievement] = Field(default_factory=list)
177    shown: bool = True

GET /users/{user_id}/achievements: the profile's 成就 (#2928).

The badges a person earned across every team they are on, one per badge (earliest unlock), newest first. Gated by the achievements visibility key (absent = shown): a stranger is sent items=[] with shown=False when it is off. For anybody but the person and a platform admin, a secret badge is left out (the team page's rule), a sitting the public career withholds contributes nothing, a badge won after a held event's freeze is not there yet, and context is empty.

items: list[TeamAchievement] = PydanticUndefined
shown: bool = True
class UserEmailInfo(ctfy.core.models.CtfyModel):
219class UserEmailInfo(CtfyModel):
220    """One address on the calling account, as `/me/emails` publishes it.
221
222    ⚠️ **``is_primary`` is a ``computed_field`` composed by the route
223    from ``users.email``, never a stored column.** There is no such
224    column: the primary *is* whichever address ``users.email`` names, so
225    a stored flag would be a second claim about the same fact, free to
226    say two addresses are primary or none is. The route knows the
227    account, so it can answer it; the table cannot be wrong about it.
228
229    ⚠️ **``verified`` mirrors ``EmailVerificationStatus`` rather than
230    inventing a second spelling** — one fact, published in the shape a
231    client reads, and never able to disagree with the timestamp beside
232    it.
233    """
234
235    email: str = ""
236    verified_at: AwareDatetime | None = None
237    #: ``""`` = the account proved this itself; a user id = a super-admin
238    #: vouched for it. Published because "verified" means two different
239    #: things to an operator reading a roster, and the account is
240    #: entitled to know which one its own row carries.
241    verified_by: str = ""
242    added_at: AwareDatetime | None = None
243    primary: bool = False
244
245    @computed_field
246    @property
247    def verified(self) -> bool:
248        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
245    @computed_field
246    @property
247    def verified(self) -> bool:
248        return self.verified_at is not None
class UserInfo(ctfy.core.models.CtfyModel):
32class UserInfo(CtfyModel):
33    """A user's public-facing profile."""
34
35    id: str
36    display_name: str = ""
37    avatar_url: str = ""
38    created_at: datetime | None = None
39    bio: str | None = None
40    country: str | None = None
41    website_url: str | None = None
42    timezone: str | None = None
43    social_links: dict[str, str] = Field(default_factory=dict)
44    # Lifetime totals across every team the user has ever been on.
45    solves: int = 0
46    attempts: int = 0
47    last_active_at: datetime | None = None
48    # A person, somebody's agent, or the platform itself — the
49    # user-level axis, one value wider than the team-level
50    # ``ScoreboardEntry.entrant_kind`` (a service never competes, so
51    # the board type cannot say it). The profile route keeps answering
52    # 200 for non-humans (activity rows link every ``team``-actor to
53    # ``/users/{id}``, so a 404 would cut those links) and this field is
54    # what lets the page say what it is showing instead of dressing an
55    # agent — or the platform — as a person. Defaults to ``human`` so an
56    # older server never paints non-human chrome on a person.
57    entrant_kind: UserKind = "human"
58    #: The institution this account publishes, or ``None``.
59    #:
60    #: ⚠️ **``None`` is the default and means *no badge*, which is the
61    #: whole decision rather than an absent value.** Deriving one from
62    #: any proved address would retroactively publish a fact about every
63    #: account that has ever verified one — including everybody who
64    #: attaches a university address this month *because* the feature
65    #: exists. The person proved the mailbox to the platform; publishing
66    #: an inference from it is a second act, and silence here is the
67    #: honest statement that they have not taken it.
68    #:
69    #: ⚠️ **Composed, never a column.** It is resolved on every read
70    #: through ``user_emails`` and the operator's catalogue
71    #: (`ctfy.server.affiliations`), so it disappears the moment the
72    #: address is removed, stops being verified, is handed to another
73    #: account, or the operator deletes the row — with no backfill and
74    #: no cleanup pass.
75    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 UserSchoolInfo(ctfy.core.models.CtfyModel):
337class UserSchoolInfo(CtfyModel):
338    """The school an account has *proved* (#2772), as the statistics count it.
339
340    Read through :func:`ctfy.core.user_stats.school_of`, the classifier the
341    school chart and its click-through filter read, so the 学校 column and
342    the bar it sits under cannot disagree. ``key`` is the filter value.
343    """
344
345    key: str
346    label: str
347    source: SchoolSource
348    role: SchoolRole
349    #: The catalogue's ``{locale: name}``, resolved in the browser
350    #: (``lib/institutionName.ts``); ``{}`` for an uncatalogued school, whose
351    #: ``label`` is its domain.
352    names: dict[str, str] = Field(default_factory=dict)

The school an account has proved (#2772), as the statistics count it.

Read through ctfy.core.user_stats.school_of(), the classifier the school chart and its click-through filter read, so the 学校 column and the bar it sits under cannot disagree. key is the filter value.

key: str = PydanticUndefined
label: str = PydanticUndefined
source: Literal['carsi', 'email'] = PydanticUndefined
role: Literal['student', 'faculty', 'unclassified'] = PydanticUndefined
names: dict[str, str] = PydanticUndefined
class UserSolveTrend(ctfy.core.models.CtfyModel):
732class UserSolveTrend(CtfyModel):
733    """The calling user's daily solve count for the last ``days`` UTC days,
734    aggregated across every team they have ever been on (matched by
735    ``user_id`` on each :class:`SolveState`).
736
737    Powers the Dashboard's "personal solve trend" chart, which is
738    user-scoped — independent of which competition the user is currently
739    registered for. Multi-flag challenges count once per flag day, the
740    same way :class:`ProfileStats.solve_trend` does it.
741    """
742
743    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):
449class VendorStandardSet(CtfyModel):
450    """A vendor-facing summary of one operator eval standard set.
451
452    A standard set is a competition marked ``eval_standard`` — the fixed
453    challenge subset a vendor self-service campaign runs a model over. This
454    projection is what a ``pv_`` token can see (id / title / size / corpus
455    pin), enough to populate the campaign-launch picker without exposing the
456    full competition surface or requiring a platform user token.
457    """
458
459    id: str = ""
460    title: str = ""
461    description: str = ""  # Markdown
462    challenge_count: int = 0
463    #: corpus snapshot the set was certified against (reproducibility pin).
464    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):
187class VendorStanding(CtfyModel):
188    """One vendor's aggregate scorecard across all its evaluated models.
189
190    The vendor's models are pooled: a challenge counts as *solved* if **any**
191    of the vendor's models solved it (the vendor's collective capability), so
192    a vendor fielding several models isn't double-counted per challenge.
193    """
194
195    rank: int = 0
196    vendor: str = ""
197    #: the vendor's curated display name, falling back to the slug. Never
198    #: blank here — an unattributed model contributes to no vendor at all,
199    #: so every row in this table has a slug to fall back to.
200    display_name: str = ""
201    models: int = 0
202    challenges_attempted: int = 0
203    challenges_solved: int = 0
204    solve_rate: float = 0.0
205    questions_solved: int = 0
206    total_tokens: int = 0
207    #: estimated USD cost summed across the vendor's models (0 when unpriced).
208    cost_usd: float = 0.0
209    #: ⚠️ **#2614: the rank and the headline are the mean over campaigns**
210    #: (``core.eval_board_mean``), one basis for every table on the page.
211    #: ``challenges_solved`` / ``solve_rate`` above pool every sample
212    #: (best-of) and stay only as coverage; nothing ranks on or shows them.
213    #: Standalone runs (no campaign) are in no mean. ``campaigns == 0`` is
214    #: unmeasured, never a zero.
215    campaigns: int = 0
216    mean_solved: float = 0.0
217    mean_attempted: float = 0.0
218    mean_solve_rate: float = 0.0
219    #: Each campaign's solve rate, oldest first: the spread, kept visible.
220    campaign_solve_rates: list[float] = Field(default_factory=list)

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
campaigns: int = 0
mean_solved: float = 0.0
mean_attempted: float = 0.0
mean_solve_rate: float = 0.0
campaign_solve_rates: list[float] = PydanticUndefined
class VerifyAddedEmailRequest(ctfy.server.models.AddEmailRequest):
266class VerifyAddedEmailRequest(AddEmailRequest):
267    """Body for ``POST /me/emails/verify``.
268
269    Carries the address as well as the code because a caller may have
270    more than one add in flight, and because the code table is keyed by
271    ``(address, purpose)`` — asking which address this code is for is
272    the lookup, not a convenience.
273    """
274
275    code: str = Field(min_length=1, max_length=16)
276    #: Go ahead even though the address is the other account's last
277    #: credential.
278    #:
279    #: ⚠️ **Required for that one case, and it defaults to refusing.**
280    #: A caller that never heard of handovers — an older client, the
281    #: CLI, an agent — must not be able to make an account unreachable
282    #: by omission: the empty case has to assert *no*, or the default is
283    #: a new claim about a row nobody can sign into afterwards. Every
284    #: other handover needs nothing, because the losing account keeps a
285    #: way in and is told what happened.
286    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):
700class VerifyAnswerRequest(CtfyModel):
701    """Oracle-mode answer check — same grader as /submissions but writes
702    no submission record. Carries the same shape as :class:`SubmissionCreate`
703    minus the audit trail.
704    """
705
706    # Which question on the challenge the agent is checking. Defaults
707    # to ``"flag"`` for single-question challenges.
708    question_id: str = "flag"
709    # The answer to verify (str for dynamic / static / single_select;
710    # list[str] for multi_select).
711    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):
714class VerifyAnswerResponse(CtfyModel):
715    correct: bool
716    # Echoes the request's ``question_id`` on a correct match; ``None``
717    # on a wrong claim. Useful for batched verification calls where the
718    # client wants to confirm which check the response refers to.
719    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):
160class VerifyEmailRequest(CtfyModel):
161    """Body for ``POST /auth/email-verification/verify``.
162
163    ⚠️ **No address field, deliberately.** The code always goes to — and
164    is checked against — the address already on the calling account. An
165    address in the body would make this endpoint a way to mail six
166    digits to anywhere, signed by the platform, which is the spam cannon
167    the sign-in flow's rate limits exist to prevent; it would also let a
168    caller verify a *different* address onto their own account.
169    """
170
171    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):
3268class VirtualGhostBoard(CtfyModel):
3269    """The source contest's standings at the instant this replay has
3270    reached, with the replayer merged in and ranked among them.
3271
3272    ⚠️ Nothing here is stored. The board is
3273    `compute_scoreboard_filtered` with an `until_iso`, which the
3274    platform has always supported — the replay's own progress and the
3275    source's progression are two reads of facts that already exist.
3276    """
3277
3278    source_competition_id: str
3279    #: How far into the replay the reader is. The client renders a clock
3280    #: from this rather than recomputing it from `starts_at`, so the two
3281    #: cannot disagree about which minute the ghosts are showing.
3282    elapsed_seconds: int = 0
3283    #: The instant in the *source* being shown. Bounded by its end.
3284    cutoff: datetime | None = None
3285    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):
3257class VirtualGhostRow(ScoreboardEntry):
3258    """A standings row on a replay's ghost board.
3259
3260    `is_me` is the one thing a client cannot derive: the replayer's row
3261    comes from a *different competition* than the ghosts', so there is no
3262    team id it could compare against.
3263    """
3264
3265    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
class VoidPreview(ctfy.core.models.CtfyModel):
76class VoidPreview(CtfyModel):
77    """What voiding would do to the board, or did.
78
79    ``rank_*`` of ``0`` means the team is not on the board (disqualified, or
80    outside the event's team set). ``teams_rescored`` counts the *other*
81    teams whose score moves -- the decay of a dynamic price is the whole
82    reason the board is recomputed rather than patched.
83    """
84
85    team_id: str
86    team_name: str = ""
87    solves: list[TeamSolveRow] = Field(default_factory=list)
88    rank_before: int = 0
89    rank_after: int = 0
90    score_before: int = 0
91    score_after: int = 0
92    first_blood: list[FirstBloodMove] = Field(default_factory=list)
93    teams_rescored: int = 0

What voiding would do to the board, or did.

rank_* of 0 means the team is not on the board (disqualified, or outside the event's team set). teams_rescored counts the other teams whose score moves -- the decay of a dynamic price is the whole reason the board is recomputed rather than patched.

team_id: str = PydanticUndefined
team_name: str = ''
solves: list[TeamSolveRow] = PydanticUndefined
rank_before: int = 0
rank_after: int = 0
score_before: int = 0
score_after: int = 0
first_blood: list[FirstBloodMove] = PydanticUndefined
teams_rescored: int = 0
class VoidPreviewRequest(ctfy.core.models.CtfyModel):
27class VoidPreviewRequest(CtfyModel):
28    """Body for ``.../solves/void-preview`` and ``.../solves/restore``."""
29
30    solves: list[SolveRef] = Field(min_length=1, max_length=200)

Body for .../solves/void-preview and .../solves/restore.

solves: list[SolveRef] = PydanticUndefined
class VoidResult(ctfy.core.models.CtfyModel):
 96class VoidResult(CtfyModel):
 97    """Answer to a void: how many solves moved, and what it did."""
 98
 99    voided: int
100    #: Computed **before** the write, so it is the effect the organiser was
101    #: shown and not a second reading of a changed board.
102    effect: VoidPreview

Answer to a void: how many solves moved, and what it did.

voided: int = PydanticUndefined
effect: VoidPreview = PydanticUndefined
class VoidSolvesRequest(ctfy.server.models.VoidPreviewRequest):
33class VoidSolvesRequest(VoidPreviewRequest):
34    """Body for ``.../solves/void``."""
35
36    # The row an appeal is argued from, and the text the team is shown.
37    reason: str = Field(min_length=10, max_length=2000)
38    case_id: str = ""
39    #: Tell the team (banner + mail). Off = a silent action: recorded, but the
40    #: team sees nothing and so has nothing to appeal. Defaults on.
41    notify_team: bool = True

Body for .../solves/void.

reason: str = PydanticUndefined
case_id: str = ''
notify_team: bool = True
class WaitlistBackfillRequest(ctfy.core.models.CtfyModel):
985class WaitlistBackfillRequest(CtfyModel):
986    """``POST /admin/competitions/{id}/registration-waitlist/backfill``."""
987
988    #: How many teams at most; ``0`` = as many as there are free places.
989    limit: int = Field(default=0, ge=0, le=1000)

POST /admin/competitions/{id}/registration-waitlist/backfill.

limit: int = 0
class WaitlistBackfillResult(ctfy.core.models.CtfyModel):
992class WaitlistBackfillResult(CtfyModel):
993    """What a backfill moved off the waitlist, in queue order."""
994
995    competition_id: str
996    promoted: list[str] = Field(default_factory=list)
997    capacity: RegistrationCapacity

What a backfill moved off the waitlist, in queue order.

competition_id: str = PydanticUndefined
promoted: list[str] = PydanticUndefined
capacity: RegistrationCapacity = PydanticUndefined
class WarnRequest(ctfy.core.models.CtfyModel):
23class WarnRequest(CtfyModel):
24    """Body for ``POST .../teams/{team_id}/warn``."""
25
26    # The text the team is shown, and what an appeal is argued from.
27    reason: str = Field(min_length=10, max_length=2000)
28    case_id: str = ""
29    #: Tell the team (banner + mail). Off = a silent action: recorded, but the
30    #: team sees nothing and so has nothing to appeal. Defaults on.
31    notify_team: bool = True

Body for POST .../teams/{team_id}/warn.

reason: str = PydanticUndefined
case_id: str = ''
notify_team: bool = True
class WrongAnswer(ctfy.core.models.CtfyModel):
66class WrongAnswer(CtfyModel):
67    """One wrong answer players keep giving. Names nobody."""
68
69    #: Verbatim but neutralised and bounded; see the module docstring.
70    value: str
71    submissions: int
72    teams: int

One wrong answer players keep giving. Names nobody.

value: str = PydanticUndefined
submissions: int = PydanticUndefined
teams: int = PydanticUndefined