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]
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
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.
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).
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).
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".
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).
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.
1830class AdminScoringChangesResponse(CtfyModel): 1831 """The organisers' copy of :class:`ScoringChangesResponse`.""" 1832 1833 changes: list[AdminScoringChangeInfo] = Field(default_factory=list)
The organisers' copy of ScoringChangesResponse.
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).
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).
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).
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).
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.
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".
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.
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.
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.
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.
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).
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.
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).
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).
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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)
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.
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.
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.
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.
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).
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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).
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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).
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.
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.
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.
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.
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.
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.
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).
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).
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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.
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.
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.
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).
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)
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.
380class AwdArenaGateLink(CtfyModel): 381 """One directed link the pre-kickoff check did not find ``ok``.""" 382 383 #: The node that asked. 384 source: str = "" 385 #: The node it asked about. 386 target: str = "" 387 fault: ArenaLinkFault = ArenaLinkFault.UNKNOWN 388 #: The sentence for this fault — what it means and where to look. 389 detail: str = ""
One directed link the pre-kickoff check did not find ok.
644class AwdArenaLink(CtfyModel): 645 """What the last round's probe found about one peer's tunnel. 646 647 ⚠️ **Three questions, because three failures need three repairs.** No 648 handshake at all is NAT, a firewall on the tunnel's UDP port, a wrong 649 address or a node that is down. A handshake with no ping is a live 650 tunnel whose far end filters ICMP. Small packets through and large 651 ones not is a path that cannot carry the tunnel's MTU — and nothing 652 else in this system can see that state: the handshake succeeds, 653 ``wg show`` is perfect, the SLA checker passes, and the attacker's 654 larger response hangs. Collapsing any two sends somebody to the wrong 655 machine. 656 657 ⚠️ **The whole object is absent when nobody has looked yet**, which is 658 the fact all three booleans cannot carry: they are ``False`` both 659 when a peer is down and when no round has probed it, and the advice 660 for those is opposite — one says wait, the other says go and look at 661 your firewall. 662 663 ⚠️ ``handshaken`` only ever **refines**: the node reports ``False`` 664 both for "never handshaked" and for "could not ask", so a positive is 665 evidence and a negative is not. 666 """ 667 668 #: When the reading was taken. 669 probed_at: AwareDatetime | None = None 670 #: The round it was taken in. ⚠️ ``-1`` means *outside a round* — a 671 #: push before kickoff or after the last one — never *never*, which 672 #: is this whole object being absent. 673 tick: int = -1 674 handshaken: bool = False 675 reachable: bool = False 676 large_ok: bool = False
What the last round's probe found about one peer's tunnel.
⚠️ Three questions, because three failures need three repairs. No
handshake at all is NAT, a firewall on the tunnel's UDP port, a wrong
address or a node that is down. A handshake with no ping is a live
tunnel whose far end filters ICMP. Small packets through and large
ones not is a path that cannot carry the tunnel's MTU — and nothing
else in this system can see that state: the handshake succeeds,
wg show is perfect, the SLA checker passes, and the attacker's
larger response hangs. Collapsing any two sends somebody to the wrong
machine.
⚠️ The whole object is absent when nobody has looked yet, which is
the fact all three booleans cannot carry: they are False both
when a peer is down and when no round has probed it, and the advice
for those is opposite — one says wait, the other says go and look at
your firewall.
⚠️ handshaken only ever refines: the node reports False
both for "never handshaked" and for "could not ask", so a positive is
evidence and a negative is not.
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.
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.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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)
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.
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)
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.
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
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.
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.
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.
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).
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
99class ChangelogSeenResponse(CtfyModel): 100 """Receipt for ``POST /changelog/mark-seen``.""" 101 102 seen_at: datetime | None = None
Receipt for POST /changelog/mark-seen.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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).
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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".
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
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.
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).
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.
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).
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.
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.
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).
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.
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.
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).
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.
487class CreateFineGrainedTokenResponse(TokenInfo): 488 """Includes the plaintext ``token`` — returned once, never persisted.""" 489 490 token: str
Includes the plaintext token — returned once, never persisted.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 = ""
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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
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.
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_errorcalls; - 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
RequestValidationErrorissues, wrapped under{"message", "issues"}by_handle_validation_error.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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).
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.
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.
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.
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:
match_cap-- evaluation already holdsmatch_limitranges, its match-time share (match_pctpercent oftotal_slots,eval_match_capacity_pct;core.eval_range_cap.eval_platform_ceiling);competition_priority--free_slotsis belowreserve_pctpercent oftotal_slots(core.eval_fair_share.holds_for_match).
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).
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.
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.
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.
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.
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).
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.
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".
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.
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).
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).
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).
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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".
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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]
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.
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.
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".
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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)
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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).
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.
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_revealednaming 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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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.
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.
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.
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.
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)
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.
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.
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.
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.
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.
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.
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).
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.
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).
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.
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.
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.
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.
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.
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).
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.
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.
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 = ""
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.
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.
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).
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).
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.
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".
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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".
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.
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.
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).
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 = ""
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.
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.
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.
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.
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).
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).
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
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 (样本不足).
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.
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.
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.
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.
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.
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.
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).
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.
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).
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.
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).
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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").
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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".
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.
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.
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.
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.
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.
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.
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>).
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.
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).
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.
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).
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).
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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").
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).
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.
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.
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.
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
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).
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
116class RiskDeviceLink(CtfyModel): 117 """A device (a fingerprint or an IP) that two or more of the accounts shared. 118 ``value`` is the full value when one of the case's clues holds it, and ``""`` 119 otherwise (a noise group, a value past the clue's cap, an erased one): the 120 picture never shows a value its clues do not.""" 121 122 kind: SightingKind 123 accounts: list[str] = Field(default_factory=list) 124 value: str = ""
A device (a fingerprint or an IP) that two or more of the accounts shared.
value is the full value when one of the case's clues holds it, and ""
otherwise (a noise group, a value past the clue's cap, an erased one): the
picture never shows a value its clues do not.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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).
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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"
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.
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"
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 )
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.
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.
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.
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).
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.
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.
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.
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.
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.
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"
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"] = ""
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.
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.
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.
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 )
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.
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.
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.
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.
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.
253class ThreadStatusRequest(CtfyModel): 254 """Mark a thread resolved, or open it again.""" 255 256 status: ThreadStatus
Mark a thread resolved, or open it again.
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.
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.
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.
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.
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.
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.
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.
605class TokenTraceLink(CtfyModel): 606 """One token in a rotation lineage, as an operator reads it. 607 608 ⚠️ **Nothing derived from the secret is on this model** — no 609 plaintext and no hash. ``AnswerTraceResponse`` publishes 610 ``answer_hash`` because that digest is the correlation key a second 611 surface matches on; a token hash has no such reader, and 612 ``hash_token`` is unsalted SHA-256, so handing it back would publish 613 the exact stored verifier for a credential to every caller of a 614 route whose whole subject is a credential that has already leaked. 615 """ 616 617 id: str 618 kind: TokenKind 619 label: str = "" 620 created_at: datetime | None = None 621 last_used_at: datetime | None = None 622 expires_at: datetime | None = None 623 revoked_at: datetime | None = None 624 rotated_from: str = "" 625 #: The row the submitted secret hashes to. Exactly one link in a 626 #: chain carries it. 627 is_traced: bool = False 628 #: Whether this token would authenticate a request **right now** — 629 #: neither revoked nor expired. 630 #: 631 #: ⚠️ Composed server-side rather than left to the reader, for 632 #: ``expects_report``'s reason: two clients deriving "is it still 633 #: live" from ``revoked_at`` and ``expires_at`` eventually disagree 634 #: with the resolver, and the direction that disagrees silently is 635 #: the one that says a leaked credential is dead. 636 live: bool = False
One token in a rotation lineage, as an operator reads it.
⚠️ Nothing derived from the secret is on this model — no
plaintext and no hash. AnswerTraceResponse publishes
answer_hash because that digest is the correlation key a second
surface matches on; a token hash has no such reader, and
hash_token is unsalted SHA-256, so handing it back would publish
the exact stored verifier for a credential to every caller of a
route whose whole subject is a credential that has already leaked.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.